Skip to content

Commit

Permalink
Events limit (#115)
Browse files Browse the repository at this point in the history
* Events are requested and parsed from the server in another thread 
* Added a Refresh button
* Filtering logic has been moved outside of the UI thread
* The events list is now rendered with lazy loading and fixed item extent, which skips some calculation from the framework
* [ui] The table header is now pinned to the top
  • Loading branch information
bdlukaa authored Jun 28, 2023
2 parents 6a1e5c0 + edbfaa2 commit a4de4d8
Show file tree
Hide file tree
Showing 13 changed files with 270 additions and 120 deletions.
56 changes: 34 additions & 22 deletions lib/api/api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import 'package:bluecherry_client/api/ptz.dart';
import 'package:bluecherry_client/models/device.dart';
import 'package:bluecherry_client/models/event.dart';
import 'package:bluecherry_client/models/server.dart';
import 'package:bluecherry_client/utils/constants.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart';
import 'package:xml2json/xml2json.dart';
Expand Down Expand Up @@ -137,40 +138,45 @@ class API {
return null;
}

/// Gets [Event]s present on the [server] after login.
/// [limit] defines the number of events to be fetched.
/// Gets the [Event]s present on the [server].
///
Future<Iterable<Event>> getEvents(
Server server, {
int limit = 50,
}) async {
/// If server is offline, then it returns an empty list.
Future<Iterable<Event>> getEvents(Server server) async {
if (!server.online) {
debugPrint('Can not get events of an offline server: $server');
return [];
}

try {
assert(server.serverUUID != null && server.cookie != null);
final response = await get(
Uri.https(
'${Uri.encodeComponent(server.login)}:${Uri.encodeComponent(server.password)}@${server.ip}:${server.port}',
'/events/',
{
'XML': '1',
'limit': '$limit',
},
),
headers: {
'Cookie': server.cookie!,
return compute(_getEvents, server);
}

static Future<Iterable<Event>> _getEvents(Server server) async {
if (!server.online) {
debugPrint('Can not get events of an offline server: $server');
return [];
}

assert(server.serverUUID != null && server.cookie != null);
final response = await get(
Uri.https(
'${Uri.encodeComponent(server.login)}:${Uri.encodeComponent(server.password)}@${server.ip}:${server.port}',
'/events/',
{
'XML': '1',
'limit': '$kEventsLimit',
},
);
),
headers: {
'Cookie': server.cookie!,
},
);

try {
debugPrint('Getting events for server ${server.name}');
// debugPrint(response.body);

final parser = Xml2Json()..parse(response.body);
return (await compute(jsonDecode, parser.toGData()))['feed']['entry']
.map((e) {
final events = jsonDecode(parser.toGData())['feed']['entry'].map((e) {
if (!e.containsKey('content')) debugPrint(e.toString());
return Event(
server,
Expand All @@ -197,6 +203,12 @@ class API {
),
);
}).cast<Event>();

debugPrint('Loaded ${events.length} events for server ${server.name}');
return events;
} on Xml2JsonException {
debugPrint('Failed to parse XML response from server $server');
debugPrint(response.body);
} catch (exception, stacktrace) {
debugPrint('Failed to getEvents on server $server');
debugPrint(exception.toString());
Expand Down
1 change: 0 additions & 1 deletion lib/api/api_helpers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ abstract class APIHelpers {
}) async {
final events = await API.instance.getEvents(
await API.instance.checkServerCredentials(server),
limit: attempts,
);
debugPrint(events.map((e) => e.id).toList().toString());
Future<String?> getThumbnailForMediaID(int mediaID) async {
Expand Down
2 changes: 1 addition & 1 deletion lib/firebase_messaging_background_handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ Future<void> _backgroundClickAction(ReceivedAction action) async {
_mutex = 'events_screen';
await navigatorKey.currentState?.push(
MaterialPageRoute(
builder: (context) => const EventsScreen(),
builder: (context) => EventsScreen(key: eventsScreenKey),
),
);
_mutex = null;
Expand Down
2 changes: 1 addition & 1 deletion lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ class UnityApp extends StatelessWidget {
if (settings.name == '/events') {
final data = settings.arguments! as Map;
final event = data['event'] as Event;
final upcomingEvents = data['upcoming'] as List<Event>;
final upcomingEvents = data['upcoming'] as Iterable<Event>;

return MaterialPageRoute(
settings: RouteSettings(
Expand Down
3 changes: 3 additions & 0 deletions lib/utils/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,6 @@ const uuid = Uuid();

/// If the screen size is smaller or equal to this, a mobile view shall be used
const kMobileBreakpoint = Size(800, 500);

/// The amount of events to load at once
const kEventsLimit = 1000;
12 changes: 12 additions & 0 deletions lib/widgets/desktop_buttons.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@ import 'package:bluecherry_client/main.dart';
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/widgets/events/events_screen.dart';
import 'package:bluecherry_client/widgets/home.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';
import 'package:unity_video_player/unity_video_player.dart';
import 'package:window_manager/window_manager.dart';
Expand Down Expand Up @@ -113,6 +115,7 @@ class _WindowButtonsState extends State<WindowButtons> with WindowListener {
if (!isDesktop || isMobile) return const SizedBox.shrink();

final theme = Theme.of(context);
final loc = AppLocalizations.of(context);

final home = context.watch<HomeProvider>();
final tab = home.tab;
Expand Down Expand Up @@ -197,6 +200,15 @@ class _WindowButtonsState extends State<WindowButtons> with WindowListener {
const Padding(
padding: EdgeInsets.symmetric(horizontal: 8.0),
child: UnityLoadingIndicator(),
)
else if (home.tab == UnityTab.eventsScreen.index && !canPop)
IconButton(
onPressed: () {
eventsScreenKey.currentState?.fetch();
},
icon: const Icon(Icons.refresh),
iconSize: 20.0,
tooltip: loc.refresh,
),
SizedBox(
width: 138,
Expand Down
2 changes: 1 addition & 1 deletion lib/widgets/events/event_player_desktop.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const kSliderControlerWidth = 100.0;
class EventPlayerDesktop extends StatefulWidget {
final Event event;

final List<Event> upcomingEvents;
final Iterable<Event> upcomingEvents;

const EventPlayerDesktop({
super.key,
Expand Down
2 changes: 1 addition & 1 deletion lib/widgets/events/event_player_mobile.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ part of 'events_screen.dart';

class EventPlayerScreen extends StatelessWidget {
final Event event;
final List<Event> upcomingEvents;
final Iterable<Event> upcomingEvents;

const EventPlayerScreen({
super.key,
Expand Down
108 changes: 83 additions & 25 deletions lib/widgets/events/events_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import 'package:bluecherry_client/widgets/downloads_manager.dart';
import 'package:bluecherry_client/widgets/error_warning.dart';
import 'package:bluecherry_client/widgets/events/event_player_desktop.dart';
import 'package:bluecherry_client/widgets/misc.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
Expand All @@ -48,10 +49,12 @@ part 'event_player_mobile.dart';
part 'events_screen_desktop.dart';
part 'events_screen_mobile.dart';

typedef EventsData = Map<Server, List<Event>>;
typedef EventsData = Map<Server, Iterable<Event>>;

final eventsScreenKey = GlobalKey<_EventsScreenState>();

class EventsScreen extends StatefulWidget {
const EventsScreen({super.key});
const EventsScreen({required super.key});

@override
State<EventsScreen> createState() => _EventsScreenState();
Expand All @@ -66,6 +69,8 @@ class _EventsScreenState extends State<EventsScreen> {
final EventsData events = {};
Map<Server, bool> invalid = {};

Iterable<Event> filteredEvents = [];

/// The devices that can't be displayed in the list
List<String> disabledDevices = [];

Expand All @@ -81,13 +86,15 @@ class _EventsScreenState extends State<EventsScreen> {
try {
// Load the events at the same time
await Future.wait(ServersProvider.instance.servers.map((server) async {
if (!server.online) return;

try {
final iterable = await API.instance.getEvents(
await API.instance.checkServerCredentials(server),
);
if (mounted) {
setState(() {
events[server] = iterable.toList();
events[server] = iterable;
invalid[server] = false;
});
}
Expand All @@ -101,6 +108,9 @@ class _EventsScreenState extends State<EventsScreen> {
debugPrint(exception.toString());
debugPrint(stacktrace.toString());
}

await computeFiltered();

home.notLoading(UnityLoadingReason.fetchingEventsHistory);
if (mounted) {
setState(() {
Expand All @@ -109,16 +119,23 @@ class _EventsScreenState extends State<EventsScreen> {
}
}

@override
Widget build(BuildContext context) {
final hasDrawer = Scaffold.hasDrawer(context);
final loc = AppLocalizations.of(context);
Future<void> computeFiltered() async {
filteredEvents = await compute(_updateFiltered, {
'events': events,
'allowedServers': allowedServers,
'timeFilter': timeFilter,
'levelFilter': levelFilter,
'disabledDevices': disabledDevices,
});
}

if (ServersProvider.instance.servers.isEmpty) {
return const NoServerWarning();
}
static Iterable<Event> _updateFiltered(Map<String, dynamic> data) {
final events = data['events'] as EventsData;
final allowedServers = data['allowedServers'] as List<Server>;
final timeFilter = data['timeFilter'] as EventsTimeFilter;
final levelFilter = data['levelFilter'] as EventsMinLevelFilter;
final disabledDevices = data['disabledDevices'] as List<String>;

final now = DateTime.now();
final hourRange = {
EventsTimeFilter.last12Hours: 12,
EventsTimeFilter.last24Hours: 24,
Expand All @@ -127,7 +144,8 @@ class _EventsScreenState extends State<EventsScreen> {
EventsTimeFilter.any: -1,
}[timeFilter]!;

final events = this.events.values.expand((events) sync* {
final now = DateTime.now();
return events.values.expand((events) sync* {
for (final event in events) {
// allow events from the allowed servers
if (!allowedServers.any((element) => event.server.ip == element.ip)) {
Expand Down Expand Up @@ -162,7 +180,30 @@ class _EventsScreenState extends State<EventsScreen> {

yield event;
}
}).toList();
});
}

/// We override setState because we need to update the filtered events
@override
void setState(VoidCallback fn) {
super.setState(fn);
// computes the events based on the filter, then update the screen
final home = context.read<HomeProvider>()
..loading(UnityLoadingReason.fetchingEventsHistory);
computeFiltered().then((_) {
if (mounted) super.setState(() {});
home.notLoading(UnityLoadingReason.fetchingEventsHistory);
});
}

@override
Widget build(BuildContext context) {
final hasDrawer = Scaffold.hasDrawer(context);
final loc = AppLocalizations.of(context);

if (ServersProvider.instance.servers.isEmpty) {
return const NoServerWarning();
}

return LayoutBuilder(builder: (context, consts) {
if (hasDrawer || consts.maxWidth < kMobileBreakpoint.width) {
Expand All @@ -171,6 +212,14 @@ class _EventsScreenState extends State<EventsScreen> {
leading: MaybeUnityDrawerButton(context),
title: Text(loc.eventBrowser),
actions: [
IconButton(
onPressed: () {
eventsScreenKey.currentState?.fetch();
},
icon: const Icon(Icons.refresh),
iconSize: 20.0,
tooltip: loc.refresh,
),
Padding(
padding: const EdgeInsetsDirectional.only(end: 15.0),
child: IconButton(
Expand All @@ -183,8 +232,8 @@ class _EventsScreenState extends State<EventsScreen> {
),
Expanded(
child: EventsScreenMobile(
events: events,
loadedServers: this.events.keys,
events: filteredEvents,
loadedServers: events.keys,
refresh: fetch,
// isFirstTimeLoading: isFirstTimeLoading,
invalid: invalid,
Expand All @@ -201,10 +250,7 @@ class _EventsScreenState extends State<EventsScreen> {
shape: const RoundedRectangleBorder(),
child: DropdownButtonHideUnderline(
child: Column(children: [
SubHeader(
loc.servers,
height: 40.0,
),
SubHeader(loc.servers, height: 40.0),
Expanded(
child: SingleChildScrollView(
child: buildTreeView(context, setState: setState),
Expand Down Expand Up @@ -264,7 +310,7 @@ class _EventsScreenState extends State<EventsScreen> {
),
),
const VerticalDivider(width: 1),
Expanded(child: EventsScreenDesktop(events: events)),
Expanded(child: EventsScreenDesktop(events: filteredEvents)),
]);
});
}
Expand Down Expand Up @@ -308,6 +354,7 @@ class _EventsScreenState extends State<EventsScreen> {
final isTriState = disabledDevices
.any((d) => server.devices.any((device) => device.rtspURL == d));
final isOffline = !server.online;
final serverEvents = events[server];

return TreeNode(
content: Row(children: [
Expand Down Expand Up @@ -355,6 +402,8 @@ class _EventsScreenState extends State<EventsScreen> {
final enabled = isOffline || !allowedServers.contains(server)
? false
: !disabledDevices.contains(device.rtspURL);
final eventsForDevice =
serverEvents?.where((event) => event.deviceID == device.id);
return TreeNode(
content: Row(children: [
IgnorePointer(
Expand All @@ -377,12 +426,21 @@ class _EventsScreenState extends State<EventsScreen> {
),
),
SizedBox(width: gapCheckboxText),
Text(
device.name,
overflow: TextOverflow.ellipsis,
maxLines: 1,
softWrap: false,
Flexible(
child: Text(
device.name,
overflow: TextOverflow.ellipsis,
maxLines: 1,
softWrap: false,
),
),
if (eventsForDevice != null) ...[
Text(
' (${eventsForDevice.length})',
style: theme.textTheme.labelSmall,
),
const SizedBox(width: 10.0),
],
]),
);
}).toList();
Expand Down
Loading

0 comments on commit a4de4d8

Please sign in to comment.