From d20f73f7408c5cb101902344ff37ffef335e0a70 Mon Sep 17 00:00:00 2001
From: Bruno D'Luka <brunodlukaa@gmail.com>
Date: Wed, 24 Jan 2024 20:23:21 -0300
Subject: [PATCH 01/13] feat: Search feature on device grid

---
 lib/widgets/collapsable_sidebar.dart          |  4 +-
 .../device_grid/desktop/desktop_sidebar.dart  | 23 ++++++-
 .../device_grid/desktop/layout_manager.dart   | 60 +++++++++++++++++--
 pubspec.lock                                  | 36 +++++++++--
 4 files changed, 107 insertions(+), 16 deletions(-)

diff --git a/lib/widgets/collapsable_sidebar.dart b/lib/widgets/collapsable_sidebar.dart
index 8a624efb..d6ac38b4 100644
--- a/lib/widgets/collapsable_sidebar.dart
+++ b/lib/widgets/collapsable_sidebar.dart
@@ -109,9 +109,7 @@ class _CollapsableSidebarState extends State<CollapsableSidebar>
               : AlignmentDirectional.topCenter,
           padding: collapsed
               ? EdgeInsetsDirectional.zero
-              : widget.left
-                  ? const EdgeInsetsDirectional.symmetric(horizontal: 5.0)
-                  : const EdgeInsetsDirectional.only(end: 5.0),
+              : const EdgeInsetsDirectional.symmetric(horizontal: 5.0),
           child: SquaredIconButton(
             key: collapseButtonKey,
             tooltip: collapsed ? loc.expand : loc.collapse,
diff --git a/lib/widgets/device_grid/desktop/desktop_sidebar.dart b/lib/widgets/device_grid/desktop/desktop_sidebar.dart
index fe2df170..51f890f7 100644
--- a/lib/widgets/device_grid/desktop/desktop_sidebar.dart
+++ b/lib/widgets/device_grid/desktop/desktop_sidebar.dart
@@ -34,6 +34,7 @@ class DesktopSidebar extends StatefulWidget {
 
 class _DesktopSidebarState extends State<DesktopSidebar> {
   bool isSidebarHovering = false;
+  String searchQuery = '';
 
   @override
   Widget build(BuildContext context) {
@@ -49,7 +50,12 @@ class _DesktopSidebarState extends State<DesktopSidebar> {
       child: Material(
         color: theme.canvasColor,
         child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
-          LayoutManager(collapseButton: widget.collapseButton),
+          LayoutManager(
+            collapseButton: widget.collapseButton,
+            onSearchChanged: (text) {
+              setState(() => searchQuery = text);
+            },
+          ),
           if (servers.servers.isEmpty)
             const Expanded(child: NoServers())
           else
@@ -66,8 +72,21 @@ class _DesktopSidebarState extends State<DesktopSidebar> {
                       ..sort((a, b) =>
                           b.online.toString().compareTo(a.online.toString())))
                       () {
-                        final devices = server.devices.sorted();
+                        final devices = server.devices
+                            .where(
+                              (device) => device.name.toLowerCase().contains(
+                                    searchQuery.toLowerCase(),
+                                  ),
+                            )
+                            .sorted();
                         final isLoading = servers.isServerLoading(server);
+                        if (!isLoading &&
+                            devices.isEmpty &&
+                            searchQuery.isNotEmpty) {
+                          return const SliverToBoxAdapter(
+                            child: SizedBox.shrink(),
+                          );
+                        }
 
                         /// Whether all the online devices are in the current view.
                         final isAllInView = devices
diff --git a/lib/widgets/device_grid/desktop/layout_manager.dart b/lib/widgets/device_grid/desktop/layout_manager.dart
index cd0ecdc1..0ba12d6a 100644
--- a/lib/widgets/device_grid/desktop/layout_manager.dart
+++ b/lib/widgets/device_grid/desktop/layout_manager.dart
@@ -37,7 +37,13 @@ import 'package:provider/provider.dart';
 class LayoutManager extends StatefulWidget {
   final Widget collapseButton;
 
-  const LayoutManager({super.key, required this.collapseButton});
+  final ValueChanged<String> onSearchChanged;
+
+  const LayoutManager({
+    super.key,
+    required this.collapseButton,
+    required this.onSearchChanged,
+  });
 
   @override
   State<LayoutManager> createState() => _LayoutManagerState();
@@ -46,6 +52,10 @@ class LayoutManager extends StatefulWidget {
 class _LayoutManagerState extends State<LayoutManager> {
   Timer? timer;
 
+  bool searchVisible = false;
+  final searchController = TextEditingController();
+  final searchFocusNode = FocusNode();
+
   @override
   void didChangeDependencies() {
     super.didChangeDependencies();
@@ -70,6 +80,8 @@ class _LayoutManagerState extends State<LayoutManager> {
   @override
   void dispose() {
     timer?.cancel();
+    searchController.dispose();
+    searchFocusNode.dispose();
     super.dispose();
   }
 
@@ -95,11 +107,22 @@ class _LayoutManagerState extends State<LayoutManager> {
               child: Row(children: [
                 widget.collapseButton,
                 const SizedBox(width: 5.0),
-                Expanded(
-                  child: Text(
-                    loc.view,
-                    maxLines: 1,
+                Expanded(child: Text(loc.view, maxLines: 1)),
+                SquaredIconButton(
+                  icon: Icon(
+                    !searchVisible ? Icons.search : Icons.search_off,
+                    size: 18.0,
+                    color: IconTheme.of(context).color,
                   ),
+                  tooltip: searchVisible
+                      ? 'Disable Search'
+                      : MaterialLocalizations.of(context).searchFieldLabel,
+                  onPressed: () {
+                    setState(() => searchVisible = !searchVisible);
+                    if (searchVisible) {
+                      searchFocusNode.requestFocus();
+                    }
+                  },
                 ),
                 SquaredIconButton(
                   icon: Icon(
@@ -146,6 +169,33 @@ class _LayoutManagerState extends State<LayoutManager> {
             },
           ),
         ),
+        AnimatedSlide(
+          offset: searchVisible ? Offset.zero : const Offset(0, 1),
+          duration: kThemeChangeDuration,
+          curve: Curves.easeInOut,
+          child: Column(children: [
+            const Divider(height: 1.0),
+            Padding(
+              padding: const EdgeInsets.all(8.0),
+              child: TextField(
+                controller: searchController,
+                focusNode: searchFocusNode,
+                decoration: const InputDecoration(
+                  hintText: 'Search',
+                  isDense: true,
+                  border: InputBorder.none,
+                  focusedBorder: InputBorder.none,
+                  enabledBorder: InputBorder.none,
+                  contentPadding: EdgeInsetsDirectional.symmetric(
+                    horizontal: 8.0,
+                    vertical: 4.0,
+                  ),
+                ),
+                onChanged: widget.onSearchChanged,
+              ),
+            )
+          ]),
+        ),
         const Divider(height: 1.0),
       ]),
     );
diff --git a/pubspec.lock b/pubspec.lock
index 91e9528c..93b2ad4e 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -461,6 +461,22 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "4.8.1"
+  leak_tracker:
+    dependency: transitive
+    description:
+      name: leak_tracker
+      sha256: "04be76c4a4bb50f14904e64749237e541e7c7bcf7ec0b196907322ab5d2fc739"
+      url: "https://pub.dev"
+    source: hosted
+    version: "9.0.16"
+  leak_tracker_testing:
+    dependency: transitive
+    description:
+      name: leak_tracker_testing
+      sha256: b06739349ec2477e943055aea30172c5c7000225f79dad4702e2ec0eda79a6ff
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.0.5"
   lints:
     dependency: transitive
     description:
@@ -489,10 +505,10 @@ packages:
     dependency: transitive
     description:
       name: material_color_utilities
-      sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41"
+      sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a"
       url: "https://pub.dev"
     source: hosted
-    version: "0.5.0"
+    version: "0.8.0"
   media_kit:
     dependency: transitive
     description:
@@ -567,10 +583,10 @@ packages:
     dependency: transitive
     description:
       name: meta
-      sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e
+      sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04
       url: "https://pub.dev"
     source: hosted
-    version: "1.10.0"
+    version: "1.11.0"
   msix:
     dependency: "direct dev"
     description:
@@ -1131,6 +1147,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "2.1.2"
+  vm_service:
+    dependency: transitive
+    description:
+      name: vm_service
+      sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957
+      url: "https://pub.dev"
+    source: hosted
+    version: "13.0.0"
   volume_controller:
     dependency: transitive
     description:
@@ -1159,10 +1183,10 @@ packages:
     dependency: transitive
     description:
       name: web
-      sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152
+      sha256: edc8a9573dd8c5a83a183dae1af2b6fd4131377404706ca4e5420474784906fa
       url: "https://pub.dev"
     source: hosted
-    version: "0.3.0"
+    version: "0.4.0"
   win32:
     dependency: transitive
     description:

From 78372b714790b5fa9d4f1317404692476a7c4089 Mon Sep 17 00:00:00 2001
From: Bruno D'Luka <brunodlukaa@gmail.com>
Date: Wed, 24 Jan 2024 20:46:48 -0300
Subject: [PATCH 02/13] feat(desktop): Search feature for timeline view

---
 lib/utils/extensions.dart                     |  9 +-
 lib/widgets/device_selector_screen.dart       |  2 +-
 lib/widgets/events/events_screen.dart         |  9 +-
 .../desktop/timeline_sidebar.dart             | 87 +++++++++++++++++--
 .../events_timeline/events_playback.dart      |  3 +-
 5 files changed, 96 insertions(+), 14 deletions(-)

diff --git a/lib/utils/extensions.dart b/lib/utils/extensions.dart
index 2746aba5..414e24e5 100644
--- a/lib/utils/extensions.dart
+++ b/lib/utils/extensions.dart
@@ -150,8 +150,13 @@ extension DateTimeExtension on DateTime {
 
 extension DeviceListExtension on Iterable<Device> {
   /// Returns this device list sorted properly
-  List<Device> sorted([Iterable? available]) {
-    final list = [...this]..sort((a, b) => a.name.compareTo(b.name));
+  List<Device> sorted({
+    Iterable? available,
+    String searchQuery = '',
+  }) {
+    final list = where((device) =>
+        device.name.toLowerCase().contains(searchQuery.toLowerCase())).toList()
+      ..sort((a, b) => a.name.compareTo(b.name));
 
     if (available != null) list.sort((a, b) => available.contains(a) ? 0 : 1);
     list.sort((a, b) => a.status ? 0 : 1);
diff --git a/lib/widgets/device_selector_screen.dart b/lib/widgets/device_selector_screen.dart
index 694cc7a8..3224e745 100644
--- a/lib/widgets/device_selector_screen.dart
+++ b/lib/widgets/device_selector_screen.dart
@@ -131,7 +131,7 @@ class DeviceSelectorScreen extends StatelessWidget {
                   addAutomaticKeepAlives: false,
                   addRepaintBoundaries: false,
                   itemBuilder: (context, index) {
-                    final devices = server.devices.sorted(available);
+                    final devices = server.devices.sorted(available: available);
                     final device = devices[index];
 
                     final isSelected = selected.contains(device);
diff --git a/lib/widgets/events/events_screen.dart b/lib/widgets/events/events_screen.dart
index 950119ba..a70f6361 100644
--- a/lib/widgets/events/events_screen.dart
+++ b/lib/widgets/events/events_screen.dart
@@ -218,6 +218,7 @@ class EventsScreenState<T extends StatefulWidget> extends State<T> {
                             setState(() => disabledDevices.add(device)),
                         onDisabledDeviceRemoved: (device) =>
                             setState(() => disabledDevices.remove(device)),
+                        searchQuery: '', // TODO(bdlukaa):
                       ),
                     ),
                   ),
@@ -354,6 +355,7 @@ class EventsScreenState<T extends StatefulWidget> extends State<T> {
                       setState(() => disabledDevices.add(device)),
                   onDisabledDeviceRemoved: (device) =>
                       setState(() => disabledDevices.remove(device)),
+                  searchQuery: '', // TODO(bldukaa): search for mobile
                 );
               }),
             ]);
@@ -383,12 +385,15 @@ class EventsDevicesPicker extends StatelessWidget {
   final ValueChanged<String> onDisabledDeviceAdded;
   final ValueChanged<String> onDisabledDeviceRemoved;
 
+  final String searchQuery;
+
   const EventsDevicesPicker({
     super.key,
     required this.events,
     required this.disabledDevices,
     required this.onDisabledDeviceAdded,
     required this.onDisabledDeviceRemoved,
+    required this.searchQuery,
     this.checkboxScale = 0.8,
     this.gapCheckboxText = 0.0,
   });
@@ -440,7 +445,9 @@ class EventsDevicesPicker extends StatelessWidget {
               if (isOffline) {
                 return <TreeNode>[];
               } else {
-                return server.devices.sorted().map((device) {
+                return server.devices
+                    .sorted(searchQuery: searchQuery)
+                    .map((device) {
                   final enabled = isOffline
                       ? false
                       : !disabledDevices.contains(device.streamURL);
diff --git a/lib/widgets/events_timeline/desktop/timeline_sidebar.dart b/lib/widgets/events_timeline/desktop/timeline_sidebar.dart
index 725727f9..cd3a8738 100644
--- a/lib/widgets/events_timeline/desktop/timeline_sidebar.dart
+++ b/lib/widgets/events_timeline/desktop/timeline_sidebar.dart
@@ -18,6 +18,7 @@
  */
 
 import 'package:auto_size_text/auto_size_text.dart';
+import 'package:bluecherry_client/utils/widgets/squared_icon_button.dart';
 import 'package:bluecherry_client/widgets/collapsable_sidebar.dart';
 import 'package:bluecherry_client/widgets/events/events_screen.dart';
 import 'package:bluecherry_client/widgets/events_timeline/events_playback.dart';
@@ -26,7 +27,7 @@ import 'package:flutter/material.dart';
 import 'package:flutter_gen/gen_l10n/app_localizations.dart';
 import 'package:intl/intl.dart';
 
-class TimelineSidebar extends StatelessWidget {
+class TimelineSidebar extends StatefulWidget {
   const TimelineSidebar({
     super.key,
     required this.date,
@@ -39,6 +40,23 @@ class TimelineSidebar extends StatelessWidget {
 
   final VoidCallback onFetch;
 
+  @override
+  State<TimelineSidebar> createState() => _TimelineSidebarState();
+}
+
+class _TimelineSidebarState extends State<TimelineSidebar> {
+  bool searchVisible = false;
+  String searchQuery = '';
+  final searchFocusNode = FocusNode();
+  final searchController = TextEditingController();
+
+  @override
+  void dispose() {
+    searchFocusNode.dispose();
+    searchController.dispose();
+    super.dispose();
+  }
+
   @override
   Widget build(BuildContext context) {
     final loc = AppLocalizations.of(context);
@@ -64,9 +82,60 @@ class TimelineSidebar extends StatelessWidget {
             SubHeader(
               loc.servers,
               height: 40.0,
-              trailing: collapseButton,
+              trailing: Row(
+                mainAxisAlignment: MainAxisAlignment.end,
+                crossAxisAlignment: CrossAxisAlignment.end,
+                children: [
+                  SquaredIconButton(
+                    icon: Icon(
+                      searchVisible ? Icons.search_off : Icons.search,
+                      size: 22.0,
+                    ),
+                    onPressed: () {
+                      setState(() => searchVisible = !searchVisible);
+                      if (searchVisible) {
+                        searchFocusNode.requestFocus();
+                      }
+                    },
+                  ),
+                  collapseButton,
+                ],
+              ),
               padding: const EdgeInsetsDirectional.only(start: 16.0, end: 4.0),
             ),
+            AnimatedSize(
+              duration: kThemeChangeDuration,
+              curve: Curves.easeInOut,
+              child: Builder(builder: (context) {
+                if (!searchVisible) return const SizedBox.shrink();
+                return Column(children: [
+                  const Divider(height: 1.0),
+                  Padding(
+                    padding: const EdgeInsets.all(8.0),
+                    child: TextField(
+                      controller: searchController,
+                      focusNode: searchFocusNode,
+                      decoration: const InputDecoration(
+                        hintText: 'Search',
+                        isDense: true,
+                        border: InputBorder.none,
+                        focusedBorder: InputBorder.none,
+                        enabledBorder: InputBorder.none,
+                        contentPadding: EdgeInsetsDirectional.symmetric(
+                          horizontal: 8.0,
+                          vertical: 4.0,
+                        ),
+                      ),
+                      onChanged: (value) {
+                        setState(() => searchQuery = value);
+                      },
+                    ),
+                  ),
+                  const Divider(height: 1.0),
+                  const SizedBox(height: 8.0),
+                ]);
+              }),
+            ),
             Expanded(
               child: StatefulBuilder(builder: (context, setState) {
                 return EventsDevicesPicker(
@@ -76,18 +145,20 @@ class TimelineSidebar extends StatelessWidget {
                       setState(() => state.disabledDevices.add(device)),
                   onDisabledDeviceRemoved: (device) =>
                       setState(() => state.disabledDevices.remove(device)),
+                  searchQuery: searchQuery,
                 );
               }),
             ),
+            const Divider(),
             SubHeader(loc.timeFilter, height: 24.0),
             ListTile(
               title: AutoSizeText(
                 () {
                   final formatter = DateFormat.MEd();
-                  if (DateUtils.isSameDay(date, DateTime.now())) {
+                  if (DateUtils.isSameDay(widget.date, DateTime.now())) {
                     return loc.today;
                   } else {
-                    return formatter.format(date);
+                    return formatter.format(widget.date);
                   }
                 }(),
                 maxLines: 1,
@@ -95,15 +166,15 @@ class TimelineSidebar extends StatelessWidget {
               onTap: () async {
                 final result = await showDatePicker(
                   context: context,
-                  initialDate: date,
+                  initialDate: widget.date,
                   firstDate: DateTime.utc(1970),
                   lastDate: DateTime.now(),
                   initialEntryMode: DatePickerEntryMode.calendarOnly,
-                  currentDate: date,
+                  currentDate: widget.date,
                 );
                 if (result != null) {
-                  debugPrint('date picked: from $date to $result');
-                  onDateChanged(result);
+                  debugPrint('date picked: from ${widget.date} to $result');
+                  widget.onDateChanged(result);
                 }
               },
             ),
diff --git a/lib/widgets/events_timeline/events_playback.dart b/lib/widgets/events_timeline/events_playback.dart
index bd1490e1..dfd0b3e1 100644
--- a/lib/widgets/events_timeline/events_playback.dart
+++ b/lib/widgets/events_timeline/events_playback.dart
@@ -149,8 +149,7 @@ class _EventsPlaybackState extends EventsScreenState<EventsPlayback> {
             timeline!.volume = 0.0;
           }
           return KeyEventResult.handled;
-        } else if (event.logicalKey == LogicalKeyboardKey.keyR ||
-            event.logicalKey == LogicalKeyboardKey.f5) {
+        } else if (event.logicalKey == LogicalKeyboardKey.f5) {
           fetch();
           return KeyEventResult.handled;
         } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {

From 655827819a3625a79e45b689d3a6bad0d452952c Mon Sep 17 00:00:00 2001
From: Bruno D'Luka <brunodlukaa@gmail.com>
Date: Wed, 24 Jan 2024 20:57:33 -0300
Subject: [PATCH 03/13] feat(desktop): Search feature for Events History

---
 .../device_grid/desktop/layout_manager.dart   |  2 +-
 lib/widgets/events/events_screen.dart         | 44 ++++++++-
 .../desktop/timeline_sidebar.dart             | 90 ++++++++++++-------
 3 files changed, 100 insertions(+), 36 deletions(-)

diff --git a/lib/widgets/device_grid/desktop/layout_manager.dart b/lib/widgets/device_grid/desktop/layout_manager.dart
index 0ba12d6a..351642b0 100644
--- a/lib/widgets/device_grid/desktop/layout_manager.dart
+++ b/lib/widgets/device_grid/desktop/layout_manager.dart
@@ -115,7 +115,7 @@ class _LayoutManagerState extends State<LayoutManager> {
                     color: IconTheme.of(context).color,
                   ),
                   tooltip: searchVisible
-                      ? 'Disable Search'
+                      ? 'Disable search'
                       : MaterialLocalizations.of(context).searchFieldLabel,
                   onPressed: () {
                     setState(() => searchVisible = !searchVisible);
diff --git a/lib/widgets/events/events_screen.dart b/lib/widgets/events/events_screen.dart
index a70f6361..62eec5e4 100644
--- a/lib/widgets/events/events_screen.dart
+++ b/lib/widgets/events/events_screen.dart
@@ -37,6 +37,7 @@ import 'package:bluecherry_client/widgets/desktop_buttons.dart';
 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/events_timeline/desktop/timeline_sidebar.dart';
 import 'package:bluecherry_client/widgets/misc.dart';
 import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';
@@ -172,6 +173,11 @@ class EventsScreenState<T extends StatefulWidget> extends State<T> {
     });
   }
 
+  bool searchVisible = false;
+  final searchController = TextEditingController();
+  final searchFocusNode = FocusNode();
+  String searchQuery = '';
+
   @override
   Widget build(BuildContext context) {
     if (ServersProvider.instance.servers.isEmpty) {
@@ -205,10 +211,42 @@ class EventsScreenState<T extends StatefulWidget> extends State<T> {
                   SubHeader(
                     loc.servers,
                     height: 38.0,
-                    trailing: Text(
-                      '${ServersProvider.instance.servers.length}',
+                    trailing: Row(
+                      mainAxisAlignment: MainAxisAlignment.end,
+                      children: [
+                        Padding(
+                          padding: const EdgeInsetsDirectional.only(end: 6.0),
+                          child: SquaredIconButton(
+                            icon: Icon(
+                              searchVisible ? Icons.search_off : Icons.search,
+                              size: 20.0,
+                            ),
+                            tooltip: searchVisible
+                                ? 'Disable search'
+                                : MaterialLocalizations.of(context)
+                                    .searchFieldLabel,
+                            onPressed: () {
+                              setState(() => searchVisible = !searchVisible);
+                              if (searchVisible) {
+                                searchFocusNode.requestFocus();
+                              }
+                            },
+                          ),
+                        ),
+                        Text(
+                          '${ServersProvider.instance.servers.length}',
+                        ),
+                      ],
                     ),
                   ),
+                  EventsSearchBar(
+                    searchVisible: searchVisible,
+                    searchController: searchController,
+                    searchFocusNode: searchFocusNode,
+                    onSearchChanged: (query) {
+                      super.setState(() => searchQuery = query);
+                    },
+                  ),
                   Expanded(
                     child: SingleChildScrollView(
                       child: EventsDevicesPicker(
@@ -218,7 +256,7 @@ class EventsScreenState<T extends StatefulWidget> extends State<T> {
                             setState(() => disabledDevices.add(device)),
                         onDisabledDeviceRemoved: (device) =>
                             setState(() => disabledDevices.remove(device)),
-                        searchQuery: '', // TODO(bdlukaa):
+                        searchQuery: searchQuery,
                       ),
                     ),
                   ),
diff --git a/lib/widgets/events_timeline/desktop/timeline_sidebar.dart b/lib/widgets/events_timeline/desktop/timeline_sidebar.dart
index cd3a8738..5daf4bce 100644
--- a/lib/widgets/events_timeline/desktop/timeline_sidebar.dart
+++ b/lib/widgets/events_timeline/desktop/timeline_sidebar.dart
@@ -91,6 +91,9 @@ class _TimelineSidebarState extends State<TimelineSidebar> {
                       searchVisible ? Icons.search_off : Icons.search,
                       size: 22.0,
                     ),
+                    tooltip: searchVisible
+                        ? 'Disable search'
+                        : MaterialLocalizations.of(context).searchFieldLabel,
                     onPressed: () {
                       setState(() => searchVisible = !searchVisible);
                       if (searchVisible) {
@@ -103,38 +106,11 @@ class _TimelineSidebarState extends State<TimelineSidebar> {
               ),
               padding: const EdgeInsetsDirectional.only(start: 16.0, end: 4.0),
             ),
-            AnimatedSize(
-              duration: kThemeChangeDuration,
-              curve: Curves.easeInOut,
-              child: Builder(builder: (context) {
-                if (!searchVisible) return const SizedBox.shrink();
-                return Column(children: [
-                  const Divider(height: 1.0),
-                  Padding(
-                    padding: const EdgeInsets.all(8.0),
-                    child: TextField(
-                      controller: searchController,
-                      focusNode: searchFocusNode,
-                      decoration: const InputDecoration(
-                        hintText: 'Search',
-                        isDense: true,
-                        border: InputBorder.none,
-                        focusedBorder: InputBorder.none,
-                        enabledBorder: InputBorder.none,
-                        contentPadding: EdgeInsetsDirectional.symmetric(
-                          horizontal: 8.0,
-                          vertical: 4.0,
-                        ),
-                      ),
-                      onChanged: (value) {
-                        setState(() => searchQuery = value);
-                      },
-                    ),
-                  ),
-                  const Divider(height: 1.0),
-                  const SizedBox(height: 8.0),
-                ]);
-              }),
+            EventsSearchBar(
+              searchVisible: searchVisible,
+              searchController: searchController,
+              searchFocusNode: searchFocusNode,
+              onSearchChanged: (query) => setState(() => searchQuery = query),
             ),
             Expanded(
               child: StatefulBuilder(builder: (context, setState) {
@@ -184,3 +160,53 @@ class _TimelineSidebarState extends State<TimelineSidebar> {
     );
   }
 }
+
+class EventsSearchBar extends StatelessWidget {
+  final bool searchVisible;
+  final TextEditingController searchController;
+  final FocusNode searchFocusNode;
+  final ValueChanged<String> onSearchChanged;
+
+  const EventsSearchBar({
+    super.key,
+    required this.searchVisible,
+    required this.searchController,
+    required this.searchFocusNode,
+    required this.onSearchChanged,
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    return AnimatedSize(
+      duration: kThemeChangeDuration,
+      curve: Curves.easeInOut,
+      child: Builder(builder: (context) {
+        if (!searchVisible) return const SizedBox.shrink();
+        return Column(children: [
+          const Divider(height: 1.0),
+          Padding(
+            padding: const EdgeInsets.all(8.0),
+            child: TextField(
+              controller: searchController,
+              focusNode: searchFocusNode,
+              decoration: const InputDecoration(
+                hintText: 'Search',
+                isDense: true,
+                border: InputBorder.none,
+                focusedBorder: InputBorder.none,
+                enabledBorder: InputBorder.none,
+                contentPadding: EdgeInsetsDirectional.symmetric(
+                  horizontal: 8.0,
+                  vertical: 4.0,
+                ),
+              ),
+              onChanged: onSearchChanged,
+            ),
+          ),
+          const Divider(height: 1.0),
+          const SizedBox(height: 8.0),
+        ]);
+      }),
+    );
+  }
+}

From 1cef53c3ec725d83b4d99d355bbe75c42e17965b Mon Sep 17 00:00:00 2001
From: Bruno D'Luka <brunodlukaa@gmail.com>
Date: Thu, 25 Jan 2024 11:10:37 -0300
Subject: [PATCH 04/13] feat(mobile): Search events on mobile events browser

---
 .../device_grid/desktop/layout_manager.dart   |  13 +-
 lib/widgets/events/events_screen.dart         | 197 ++-------------
 lib/widgets/events/filter.dart                | 234 ++++++++++++++++++
 .../desktop/timeline_sidebar.dart             |  41 ++-
 4 files changed, 296 insertions(+), 189 deletions(-)
 create mode 100644 lib/widgets/events/filter.dart

diff --git a/lib/widgets/device_grid/desktop/layout_manager.dart b/lib/widgets/device_grid/desktop/layout_manager.dart
index 351642b0..ee50e70c 100644
--- a/lib/widgets/device_grid/desktop/layout_manager.dart
+++ b/lib/widgets/device_grid/desktop/layout_manager.dart
@@ -26,6 +26,7 @@ import 'package:bluecherry_client/providers/settings_provider.dart';
 import 'package:bluecherry_client/utils/methods.dart';
 import 'package:bluecherry_client/utils/widgets/squared_icon_button.dart';
 import 'package:bluecherry_client/utils/window.dart';
+import 'package:bluecherry_client/widgets/events_timeline/desktop/timeline_sidebar.dart';
 import 'package:bluecherry_client/widgets/hover_button.dart';
 import 'package:bluecherry_client/widgets/misc.dart';
 import 'package:file_picker/file_picker.dart';
@@ -108,15 +109,9 @@ class _LayoutManagerState extends State<LayoutManager> {
                 widget.collapseButton,
                 const SizedBox(width: 5.0),
                 Expanded(child: Text(loc.view, maxLines: 1)),
-                SquaredIconButton(
-                  icon: Icon(
-                    !searchVisible ? Icons.search : Icons.search_off,
-                    size: 18.0,
-                    color: IconTheme.of(context).color,
-                  ),
-                  tooltip: searchVisible
-                      ? 'Disable search'
-                      : MaterialLocalizations.of(context).searchFieldLabel,
+                EventsSearchButton(
+                  searchVisible: searchVisible,
+                  iconSize: 18.0,
                   onPressed: () {
                     setState(() => searchVisible = !searchVisible);
                     if (searchVisible) {
diff --git a/lib/widgets/events/events_screen.dart b/lib/widgets/events/events_screen.dart
index 62eec5e4..e4e8e60a 100644
--- a/lib/widgets/events/events_screen.dart
+++ b/lib/widgets/events/events_screen.dart
@@ -31,12 +31,12 @@ import 'package:bluecherry_client/utils/constants.dart';
 import 'package:bluecherry_client/utils/extensions.dart';
 import 'package:bluecherry_client/utils/methods.dart';
 import 'package:bluecherry_client/utils/widgets/squared_icon_button.dart';
-import 'package:bluecherry_client/utils/widgets/tree_view.dart';
 import 'package:bluecherry_client/widgets/collapsable_sidebar.dart';
 import 'package:bluecherry_client/widgets/desktop_buttons.dart';
 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/events/filter.dart';
 import 'package:bluecherry_client/widgets/events_timeline/desktop/timeline_sidebar.dart';
 import 'package:bluecherry_client/widgets/misc.dart';
 import 'package:flutter/foundation.dart';
@@ -216,15 +216,8 @@ class EventsScreenState<T extends StatefulWidget> extends State<T> {
                       children: [
                         Padding(
                           padding: const EdgeInsetsDirectional.only(end: 6.0),
-                          child: SquaredIconButton(
-                            icon: Icon(
-                              searchVisible ? Icons.search_off : Icons.search,
-                              size: 20.0,
-                            ),
-                            tooltip: searchVisible
-                                ? 'Disable search'
-                                : MaterialLocalizations.of(context)
-                                    .searchFieldLabel,
+                          child: EventsSearchButton(
+                            searchVisible: searchVisible,
                             onPressed: () {
                               setState(() => searchVisible = !searchVisible);
                               if (searchVisible) {
@@ -345,7 +338,7 @@ class EventsScreenState<T extends StatefulWidget> extends State<T> {
     /// This is used to update the screen when the bottom sheet is closed.
     var hasChanged = false;
 
-    await showModalBottomSheet(
+    await showModalBottomSheet<bool>(
       context: context,
       isScrollControlled: true,
       showDragHandle: true,
@@ -355,48 +348,29 @@ class EventsScreenState<T extends StatefulWidget> extends State<T> {
           maxChildSize: 0.85,
           initialChildSize: 0.85,
           builder: (context, controller) {
-            final loc = AppLocalizations.of(context);
-            return ListView(controller: controller, children: [
-              SubHeader(loc.timeFilter, height: 20.0),
-              buildTimeFilterTile(onSelect: () => hasChanged = true),
-              // const SubHeader('Minimum level'),
-              // DropdownButtonHideUnderline(
-              //   child: DropdownButton<EventsMinLevelFilter>(
-              //     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,
-              //     ),
-              //   ),
-              // ),
-              SubHeader(loc.servers, height: 36.0),
-              StatefulBuilder(builder: (context, localSetState) {
-                void setState(VoidCallback callback) {
-                  callback();
+            return PrimaryScrollController(
+              controller: controller,
+              child: MobileFilterSheet(
+                events: events,
+                disabledDevices: disabledDevices,
+                onDisabledDeviceAdded: (device) {
+                  setState(() => disabledDevices.add(device));
                   hasChanged = true;
-                  this.setState(() {});
-                  localSetState(() {});
-                }
-
-                return EventsDevicesPicker(
-                  events: events,
-                  disabledDevices: disabledDevices,
-                  gapCheckboxText: 10.0,
-                  checkboxScale: 1.15,
-                  onDisabledDeviceAdded: (device) =>
-                      setState(() => disabledDevices.add(device)),
-                  onDisabledDeviceRemoved: (device) =>
-                      setState(() => disabledDevices.remove(device)),
-                  searchQuery: '', // TODO(bldukaa): search for mobile
-                );
-              }),
-            ]);
+                },
+                onDisabledDeviceRemoved: (device) {
+                  setState(() => disabledDevices.remove(device));
+                  hasChanged = true;
+                },
+                levelFilter: levelFilter,
+                onLevelFilterChanged: (filter) {
+                  setState(() => levelFilter = filter);
+                  hasChanged = true;
+                },
+                timeFilterTile: buildTimeFilterTile(onSelect: () {
+                  hasChanged = true;
+                }),
+              ),
+            );
           },
         );
       },
@@ -405,122 +379,3 @@ class EventsScreenState<T extends StatefulWidget> extends State<T> {
     if (hasChanged) fetch();
   }
 }
-
-enum EventsMinLevelFilter {
-  any,
-  info,
-  warning,
-  alarming,
-  critical,
-}
-
-class EventsDevicesPicker extends StatelessWidget {
-  final EventsData events;
-  final Set<String> disabledDevices;
-  final double checkboxScale;
-  final double gapCheckboxText;
-
-  final ValueChanged<String> onDisabledDeviceAdded;
-  final ValueChanged<String> onDisabledDeviceRemoved;
-
-  final String searchQuery;
-
-  const EventsDevicesPicker({
-    super.key,
-    required this.events,
-    required this.disabledDevices,
-    required this.onDisabledDeviceAdded,
-    required this.onDisabledDeviceRemoved,
-    required this.searchQuery,
-    this.checkboxScale = 0.8,
-    this.gapCheckboxText = 0.0,
-  });
-
-  @override
-  Widget build(BuildContext context) {
-    final servers = context.watch<ServersProvider>();
-
-    return SingleChildScrollView(
-      child: TreeView(
-        indent: 56,
-        iconSize: 18.0,
-        nodes: servers.servers.map((server) {
-          final disabledDevicesForServer = disabledDevices.where(
-              (d) => server.devices.any((device) => device.streamURL == d));
-          final isTriState = disabledDevices.any(
-              (d) => server.devices.any((device) => device.streamURL == d));
-          final isOffline = !server.online;
-          final serverEvents = events[server];
-
-          return TreeNode(
-            content: buildCheckbox(
-              value: disabledDevicesForServer.length == server.devices.length ||
-                      isOffline
-                  ? false
-                  : isTriState
-                      ? null
-                      : true,
-              isError: isOffline,
-              onChanged: (v) {
-                if (v == true) {
-                  for (final d in server.devices) {
-                    onDisabledDeviceRemoved(d.streamURL);
-                  }
-                } else if (v == null || !v) {
-                  for (final d in server.devices) {
-                    onDisabledDeviceAdded(d.streamURL);
-                  }
-                }
-              },
-              checkboxScale: checkboxScale,
-              text: server.name,
-              secondaryText: isOffline ? null : '${server.devices.length}',
-              gapCheckboxText: gapCheckboxText,
-              textFit: FlexFit.tight,
-              offlineIcon: Icons.domain_disabled_outlined,
-            ),
-            children: () {
-              if (isOffline) {
-                return <TreeNode>[];
-              } else {
-                return server.devices
-                    .sorted(searchQuery: searchQuery)
-                    .map((device) {
-                  final enabled = isOffline
-                      ? false
-                      : !disabledDevices.contains(device.streamURL);
-                  final eventsForDevice = serverEvents
-                      ?.where((event) => event.deviceID == device.id);
-                  return TreeNode(
-                    content: IgnorePointer(
-                      ignoring: !device.status,
-                      child: buildCheckbox(
-                        value: device.status ? enabled : false,
-                        isError: !device.status,
-                        onChanged: (v) {
-                          if (!device.status) return;
-
-                          if (enabled) {
-                            onDisabledDeviceAdded(device.streamURL);
-                          } else {
-                            onDisabledDeviceRemoved(device.streamURL);
-                          }
-                        },
-                        checkboxScale: checkboxScale,
-                        text: device.name,
-                        secondaryText: eventsForDevice != null && device.status
-                            ? ' (${eventsForDevice.length})'
-                            : null,
-                        gapCheckboxText: gapCheckboxText,
-                      ),
-                    ),
-                  );
-                }).toList();
-              }
-            }(),
-          );
-        }).toList(),
-      ),
-    );
-  }
-}
diff --git a/lib/widgets/events/filter.dart b/lib/widgets/events/filter.dart
new file mode 100644
index 00000000..0054c10b
--- /dev/null
+++ b/lib/widgets/events/filter.dart
@@ -0,0 +1,234 @@
+/*
+ * 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 <http://www.gnu.org/licenses/>.
+ */
+
+import 'package:bluecherry_client/providers/server_provider.dart';
+import 'package:bluecherry_client/utils/extensions.dart';
+import 'package:bluecherry_client/utils/widgets/tree_view.dart';
+import 'package:bluecherry_client/widgets/events/events_screen.dart';
+import 'package:bluecherry_client/widgets/events_timeline/desktop/timeline_sidebar.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';
+
+enum EventsMinLevelFilter { any, info, warning, alarming, critical }
+
+class EventsDevicesPicker extends StatelessWidget {
+  final EventsData events;
+  final Set<String> disabledDevices;
+  final double checkboxScale;
+  final double gapCheckboxText;
+
+  final ValueChanged<String> onDisabledDeviceAdded;
+  final ValueChanged<String> onDisabledDeviceRemoved;
+
+  final String searchQuery;
+
+  const EventsDevicesPicker({
+    super.key,
+    required this.events,
+    required this.disabledDevices,
+    required this.onDisabledDeviceAdded,
+    required this.onDisabledDeviceRemoved,
+    required this.searchQuery,
+    this.checkboxScale = 0.8,
+    this.gapCheckboxText = 0.0,
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    final servers = context.watch<ServersProvider>();
+
+    return SingleChildScrollView(
+      child: TreeView(
+        indent: 56,
+        iconSize: 18.0,
+        nodes: servers.servers.map((server) {
+          final disabledDevicesForServer = disabledDevices.where(
+              (d) => server.devices.any((device) => device.streamURL == d));
+          final isTriState = disabledDevices.any(
+              (d) => server.devices.any((device) => device.streamURL == d));
+          final isOffline = !server.online;
+          final serverEvents = events[server];
+
+          return TreeNode(
+            content: buildCheckbox(
+              value: disabledDevicesForServer.length == server.devices.length ||
+                      isOffline
+                  ? false
+                  : isTriState
+                      ? null
+                      : true,
+              isError: isOffline,
+              onChanged: (v) {
+                if (v == true) {
+                  for (final d in server.devices) {
+                    onDisabledDeviceRemoved(d.streamURL);
+                  }
+                } else if (v == null || !v) {
+                  for (final d in server.devices) {
+                    onDisabledDeviceAdded(d.streamURL);
+                  }
+                }
+              },
+              checkboxScale: checkboxScale,
+              text: server.name,
+              secondaryText: isOffline ? null : '${server.devices.length}',
+              gapCheckboxText: gapCheckboxText,
+              textFit: FlexFit.tight,
+              offlineIcon: Icons.domain_disabled_outlined,
+            ),
+            children: () {
+              if (isOffline) {
+                return <TreeNode>[];
+              } else {
+                return server.devices
+                    .sorted(searchQuery: searchQuery)
+                    .map((device) {
+                  final enabled = isOffline
+                      ? false
+                      : !disabledDevices.contains(device.streamURL);
+                  final eventsForDevice = serverEvents
+                      ?.where((event) => event.deviceID == device.id);
+                  return TreeNode(
+                    content: IgnorePointer(
+                      ignoring: !device.status,
+                      child: buildCheckbox(
+                        value: device.status ? enabled : false,
+                        isError: !device.status,
+                        onChanged: (v) {
+                          if (!device.status) return;
+
+                          if (enabled) {
+                            onDisabledDeviceAdded(device.streamURL);
+                          } else {
+                            onDisabledDeviceRemoved(device.streamURL);
+                          }
+                        },
+                        checkboxScale: checkboxScale,
+                        text: device.name,
+                        secondaryText: eventsForDevice != null && device.status
+                            ? ' (${eventsForDevice.length})'
+                            : null,
+                        gapCheckboxText: gapCheckboxText,
+                      ),
+                    ),
+                  );
+                }).toList();
+              }
+            }(),
+          );
+        }).toList(),
+      ),
+    );
+  }
+}
+
+class MobileFilterSheet extends StatefulWidget {
+  final EventsData events;
+  final Set<String> disabledDevices;
+
+  final ValueChanged<String> onDisabledDeviceAdded;
+  final ValueChanged<String> onDisabledDeviceRemoved;
+
+  final EventsMinLevelFilter levelFilter;
+  final ValueChanged<EventsMinLevelFilter> onLevelFilterChanged;
+
+  final Widget timeFilterTile;
+
+  const MobileFilterSheet({
+    super.key,
+    required this.events,
+    required this.disabledDevices,
+    required this.onDisabledDeviceAdded,
+    required this.onDisabledDeviceRemoved,
+    required this.levelFilter,
+    required this.onLevelFilterChanged,
+    required this.timeFilterTile,
+  });
+
+  @override
+  State<MobileFilterSheet> createState() => _MobileFilterSheetState();
+}
+
+class _MobileFilterSheetState extends State<MobileFilterSheet> {
+  final searchController = TextEditingController();
+  final searchFocusNode = FocusNode();
+  String searchQuery = '';
+  bool searchVisible = false;
+
+  @override
+  void dispose() {
+    searchController.dispose();
+    searchFocusNode.dispose();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final loc = AppLocalizations.of(context);
+    return ListView(primary: true, children: [
+      SubHeader(loc.timeFilter, height: 20.0),
+      widget.timeFilterTile,
+      const SubHeader('Minimum level', height: 20.0),
+      DropdownButtonHideUnderline(
+        child: DropdownButton<EventsMinLevelFilter>(
+          isExpanded: true,
+          value: widget.levelFilter,
+          items: EventsMinLevelFilter.values.map((level) {
+            return DropdownMenuItem(
+              value: level,
+              child: Text(level.name.uppercaseFirst()),
+            );
+          }).toList(),
+          onChanged: (filter) {
+            if (filter != null) {
+              widget.onLevelFilterChanged(filter);
+            }
+          },
+        ),
+      ),
+      SubHeader(
+        loc.servers,
+        height: 38.0,
+        trailing: EventsSearchButton(
+          searchVisible: searchVisible,
+          onPressed: () => setState(() => searchVisible = !searchVisible),
+        ),
+      ),
+      EventsSearchBar(
+        searchVisible: searchVisible,
+        searchController: searchController,
+        searchFocusNode: searchFocusNode,
+        onSearchChanged: (query) => setState(() => searchQuery = query),
+      ),
+      EventsDevicesPicker(
+        events: widget.events,
+        disabledDevices: widget.disabledDevices,
+        gapCheckboxText: 10.0,
+        checkboxScale: 1.15,
+        onDisabledDeviceAdded: (device) =>
+            setState(() => widget.disabledDevices.add(device)),
+        onDisabledDeviceRemoved: (device) =>
+            setState(() => widget.disabledDevices.remove(device)),
+        searchQuery: searchQuery,
+      )
+    ]);
+  }
+}
diff --git a/lib/widgets/events_timeline/desktop/timeline_sidebar.dart b/lib/widgets/events_timeline/desktop/timeline_sidebar.dart
index 5daf4bce..9c8fff06 100644
--- a/lib/widgets/events_timeline/desktop/timeline_sidebar.dart
+++ b/lib/widgets/events_timeline/desktop/timeline_sidebar.dart
@@ -20,7 +20,7 @@
 import 'package:auto_size_text/auto_size_text.dart';
 import 'package:bluecherry_client/utils/widgets/squared_icon_button.dart';
 import 'package:bluecherry_client/widgets/collapsable_sidebar.dart';
-import 'package:bluecherry_client/widgets/events/events_screen.dart';
+import 'package:bluecherry_client/widgets/events/filter.dart';
 import 'package:bluecherry_client/widgets/events_timeline/events_playback.dart';
 import 'package:bluecherry_client/widgets/misc.dart';
 import 'package:flutter/material.dart';
@@ -86,14 +86,9 @@ class _TimelineSidebarState extends State<TimelineSidebar> {
                 mainAxisAlignment: MainAxisAlignment.end,
                 crossAxisAlignment: CrossAxisAlignment.end,
                 children: [
-                  SquaredIconButton(
-                    icon: Icon(
-                      searchVisible ? Icons.search_off : Icons.search,
-                      size: 22.0,
-                    ),
-                    tooltip: searchVisible
-                        ? 'Disable search'
-                        : MaterialLocalizations.of(context).searchFieldLabel,
+                  EventsSearchButton(
+                    searchVisible: searchVisible,
+                    iconSize: 22.0,
                     onPressed: () {
                       setState(() => searchVisible = !searchVisible);
                       if (searchVisible) {
@@ -161,6 +156,34 @@ class _TimelineSidebarState extends State<TimelineSidebar> {
   }
 }
 
+class EventsSearchButton extends StatelessWidget {
+  final bool searchVisible;
+  final VoidCallback onPressed;
+
+  final double iconSize;
+
+  const EventsSearchButton({
+    super.key,
+    required this.searchVisible,
+    required this.onPressed,
+    this.iconSize = 20.0,
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    return SquaredIconButton(
+      icon: Icon(
+        searchVisible ? Icons.search_off : Icons.search,
+        size: iconSize,
+      ),
+      tooltip: searchVisible
+          ? 'Disable search'
+          : MaterialLocalizations.of(context).searchFieldLabel,
+      onPressed: onPressed,
+    );
+  }
+}
+
 class EventsSearchBar extends StatelessWidget {
   final bool searchVisible;
   final TextEditingController searchController;

From f0f97c81fe5157bce9759077d699fce1e3a9238d Mon Sep 17 00:00:00 2001
From: Bruno D'Luka <brunodlukaa@gmail.com>
Date: Mon, 29 Jan 2024 14:38:22 -0300
Subject: [PATCH 05/13] feat(mobile): Search devices on direct camera

---
 lib/widgets/direct_camera.dart | 65 +++++++++++++++++++++++++++++++---
 lib/widgets/home.dart          | 32 ++++++++++++-----
 2 files changed, 84 insertions(+), 13 deletions(-)

diff --git a/lib/widgets/direct_camera.dart b/lib/widgets/direct_camera.dart
index bd85be1b..5acad4fd 100644
--- a/lib/widgets/direct_camera.dart
+++ b/lib/widgets/direct_camera.dart
@@ -25,28 +25,79 @@ import 'package:bluecherry_client/utils/extensions.dart';
 import 'package:bluecherry_client/utils/theme.dart';
 import 'package:bluecherry_client/utils/video_player.dart';
 import 'package:bluecherry_client/widgets/error_warning.dart';
+import 'package:bluecherry_client/widgets/events_timeline/desktop/timeline_sidebar.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:sliver_tools/sliver_tools.dart';
 
-class DirectCameraScreen extends StatelessWidget {
+class DirectCameraScreen extends StatefulWidget {
   const DirectCameraScreen({super.key});
 
+  @override
+  State<DirectCameraScreen> createState() => DirectCameraScreenState();
+}
+
+class DirectCameraScreenState extends State<DirectCameraScreen> {
+  bool _searchVisible = false;
+  bool get searchVisible => _searchVisible;
+  String _searchQuery = '';
+  final _searchFocusNode = FocusNode();
+  final _searchController = TextEditingController();
+
+  void toggleSearch() {
+    setState(() {
+      _searchVisible = !_searchVisible;
+    });
+    if (_searchVisible) {
+      _searchFocusNode.requestFocus();
+    } else {
+      _searchFocusNode.unfocus();
+    }
+  }
+
+  @override
+  void dispose() {
+    _searchFocusNode.dispose();
+    _searchController.dispose();
+    super.dispose();
+  }
+
   @override
   Widget build(BuildContext context) {
     final serversProviders = context.watch<ServersProvider>();
     final loc = AppLocalizations.of(context);
     final hasDrawer = Scaffold.hasDrawer(context);
 
+    final searchBar = EventsSearchBar(
+      searchController: _searchController,
+      searchFocusNode: _searchFocusNode,
+      searchVisible: _searchVisible,
+      onSearchChanged: (text) {
+        setState(() => _searchQuery = text);
+      },
+    );
+
     return SafeArea(
       child: Column(mainAxisSize: MainAxisSize.min, children: [
-        if (hasDrawer)
+        if (hasDrawer) ...[
           AppBar(
             leading: MaybeUnityDrawerButton(context),
             title: Text(loc.directCamera),
+            actions: [
+              Padding(
+                padding: const EdgeInsetsDirectional.only(end: 12.0),
+                child: EventsSearchButton(
+                  searchVisible: _searchVisible,
+                  onPressed: toggleSearch,
+                ),
+              ),
+            ],
           ),
+          searchBar,
+        ] else
+          searchBar,
         Expanded(child: () {
           if (serversProviders.servers.isEmpty) {
             return const NoServerWarning();
@@ -60,6 +111,7 @@ class DirectCameraScreen extends StatelessWidget {
                       server: server,
                       isCompact: hasDrawer ||
                           consts.maxWidth < kMobileBreakpoint.width,
+                      searchQuery: _searchVisible ? _searchQuery : '',
                     );
                   }),
                 ]),
@@ -75,8 +127,13 @@ class DirectCameraScreen extends StatelessWidget {
 class _DevicesForServer extends StatelessWidget {
   final Server server;
   final bool isCompact;
+  final String searchQuery;
 
-  const _DevicesForServer({required this.server, required this.isCompact});
+  const _DevicesForServer({
+    required this.server,
+    required this.isCompact,
+    required this.searchQuery,
+  });
 
   @override
   Widget build(BuildContext context) {
@@ -122,7 +179,7 @@ class _DevicesForServer extends StatelessWidget {
       ]);
     }
 
-    final devices = server.devices.sorted();
+    final devices = server.devices.sorted(searchQuery: searchQuery);
 
     if (isCompact) {
       return MultiSliver(pushPinnedChildren: true, children: [
diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart
index 1bbf7952..950f5cb7 100644
--- a/lib/widgets/home.dart
+++ b/lib/widgets/home.dart
@@ -26,6 +26,7 @@ import 'package:bluecherry_client/widgets/device_grid/device_grid.dart';
 import 'package:bluecherry_client/widgets/direct_camera.dart';
 import 'package:bluecherry_client/widgets/downloads_manager.dart';
 import 'package:bluecherry_client/widgets/events/events_screen.dart';
+import 'package:bluecherry_client/widgets/events_timeline/desktop/timeline_sidebar.dart';
 import 'package:bluecherry_client/widgets/events_timeline/events_playback.dart';
 import 'package:bluecherry_client/widgets/servers/add_server.dart';
 import 'package:bluecherry_client/widgets/settings/settings.dart';
@@ -116,6 +117,8 @@ class _MobileHomeState extends State<Home> {
     });
   }
 
+  final directCameraKey = GlobalKey<DirectCameraScreenState>();
+
   @override
   Widget build(BuildContext context) {
     final home = context.watch<HomeProvider>();
@@ -163,7 +166,8 @@ class _MobileHomeState extends State<Home> {
                     },
                     child: switch (tab) {
                       UnityTab.deviceGrid => const DeviceGrid(),
-                      UnityTab.directCameraScreen => const DirectCameraScreen(),
+                      UnityTab.directCameraScreen =>
+                        DirectCameraScreen(key: directCameraKey),
                       UnityTab.eventsPlayback => EventsPlayback(),
                       UnityTab.eventsScreen =>
                         EventsScreen(key: eventsScreenKey),
@@ -334,14 +338,24 @@ class _MobileHomeState extends State<Home> {
             },
           ),
         ),
-        SizedBox(
-          height: imageSize + 16.0,
-          child: () {
-            if (home.isLoading) {
-              return const Center(child: UnityLoadingIndicator());
-            }
-          }(),
-        ),
+        if (directCameraKey.currentState != null)
+          EventsSearchButton(
+            searchVisible: directCameraKey.currentState!.searchVisible,
+            onPressed: () {
+              directCameraKey.currentState!.toggleSearch();
+              setState(() {});
+            },
+            iconSize: 24.0,
+          ),
+        if (home.isLoading)
+          SizedBox(
+            height: imageSize + 16.0,
+            child: () {
+              if (home.isLoading) {
+                return const Center(child: UnityLoadingIndicator());
+              }
+            }(),
+          ),
       ]),
     );
   }

From 6aba972408c1e638f4230561888c5853e30f6c4e Mon Sep 17 00:00:00 2001
From: Bruno D'Luka <brunodlukaa@gmail.com>
Date: Mon, 29 Jan 2024 15:27:13 -0300
Subject: [PATCH 06/13] feat: Show the server devices when a new server is
 added

---
 lib/providers/server_provider.dart            |   2 +-
 .../device_grid/desktop/desktop_sidebar.dart  |   7 +-
 lib/widgets/servers/add_server.dart           | 250 +++++++++++++-----
 3 files changed, 186 insertions(+), 73 deletions(-)

diff --git a/lib/providers/server_provider.dart b/lib/providers/server_provider.dart
index 5fbadefa..0f7c43bc 100644
--- a/lib/providers/server_provider.dart
+++ b/lib/providers/server_provider.dart
@@ -65,7 +65,7 @@ class ServersProvider extends UnityProvider {
 
     servers.add(server);
     await save();
-    refreshDevices();
+    await refreshDevices(ids: [server.id]);
 
     if (isMobilePlatform) {
       // Register notification token.
diff --git a/lib/widgets/device_grid/desktop/desktop_sidebar.dart b/lib/widgets/device_grid/desktop/desktop_sidebar.dart
index 51f890f7..f93991a4 100644
--- a/lib/widgets/device_grid/desktop/desktop_sidebar.dart
+++ b/lib/widgets/device_grid/desktop/desktop_sidebar.dart
@@ -260,10 +260,12 @@ class DesktopDeviceSelectorTile extends StatefulWidget {
     super.key,
     required this.device,
     required this.selected,
+    this.selectable = true,
   });
 
   final Device device;
   final bool selected;
+  final bool selectable;
 
   @override
   State<DesktopDeviceSelectorTile> createState() =>
@@ -298,7 +300,7 @@ class _DesktopDeviceSelectorTileState extends State<DesktopDeviceSelectorTile> {
       },
       onLongPressDown: (details) => currentLongPressDeviceKind = details.kind,
       child: InkWell(
-        onTap: !widget.device.status
+        onTap: !widget.device.status || !widget.selectable
             ? null
             : () {
                 if (widget.selected) {
@@ -309,12 +311,15 @@ class _DesktopDeviceSelectorTileState extends State<DesktopDeviceSelectorTile> {
               },
         child: MouseRegion(
           onEnter: (_) {
+            if (!widget.selectable) return;
             if (mounted) setState(() => hovering = true);
           },
           onHover: (_) {
+            if (!widget.selectable) return;
             if (mounted && !hovering) setState(() => hovering = true);
           },
           onExit: (_) {
+            if (!widget.selectable) return;
             if (mounted) setState(() => hovering = false);
           },
           child: SizedBox(
diff --git a/lib/widgets/servers/add_server.dart b/lib/widgets/servers/add_server.dart
index 77b6c2bd..1a582f00 100644
--- a/lib/widgets/servers/add_server.dart
+++ b/lib/widgets/servers/add_server.dart
@@ -23,9 +23,11 @@ import 'package:bluecherry_client/providers/home_provider.dart';
 import 'package:bluecherry_client/providers/server_provider.dart';
 import 'package:bluecherry_client/providers/settings_provider.dart';
 import 'package:bluecherry_client/utils/constants.dart';
+import 'package:bluecherry_client/utils/extensions.dart';
 import 'package:bluecherry_client/utils/methods.dart';
 import 'package:bluecherry_client/utils/widgets/squared_icon_button.dart';
 import 'package:bluecherry_client/widgets/device_grid/desktop/stream_data.dart';
+import 'package:bluecherry_client/widgets/device_grid/device_grid.dart';
 import 'package:bluecherry_client/widgets/misc.dart';
 import 'package:bluecherry_client/widgets/servers/error.dart';
 import 'package:flutter/material.dart';
@@ -68,6 +70,7 @@ Widget _buildCardAppBar({
         Text(
           description,
           style: theme.textTheme.headlineMedium,
+          softWrap: true,
         ),
         const SizedBox(height: 20.0),
       ],
@@ -75,6 +78,12 @@ Widget _buildCardAppBar({
   });
 }
 
+enum _ServerAddState {
+  none,
+  checkingServerCredentials,
+  gettingDevices;
+}
+
 class AddServerWizard extends StatefulWidget {
   final VoidCallback onFinish;
 
@@ -343,6 +352,8 @@ class _ConfigureDVRServerScreenState extends State<ConfigureDVRServerScreen> {
     return Uri.parse(text).host;
   }
 
+  _ServerAddState state = _ServerAddState.none;
+
   @override
   void dispose() {
     hostnameController.dispose();
@@ -612,14 +623,26 @@ class _ConfigureDVRServerScreenState extends State<ConfigureDVRServerScreen> {
                     ),
                     const SizedBox(height: 16.0),
                     Row(mainAxisAlignment: MainAxisAlignment.end, children: [
-                      if (disableFinishButton)
+                      if (disableFinishButton) ...[
                         const SizedBox(
-                          height: 24.0,
-                          width: 24.0,
+                          height: 18.0,
+                          width: 18.0,
                           child: CircularProgressIndicator.adaptive(
                             strokeWidth: 2.0,
                           ),
                         ),
+                        Padding(
+                          padding: const EdgeInsetsDirectional.symmetric(
+                            horizontal: 8.0,
+                          ),
+                          child: Text(switch (state) {
+                            _ServerAddState.checkingServerCredentials =>
+                              'Checking server credentials',
+                            _ServerAddState.gettingDevices => 'Getting devices',
+                            _ServerAddState.none => '',
+                          }),
+                        ),
+                      ],
                       FocusTraversalOrder(
                         order: const NumericFocusOrder(8),
                         child: MaterialButton(
@@ -687,7 +710,13 @@ class _ConfigureDVRServerScreenState extends State<ConfigureDVRServerScreen> {
         return;
       }
 
-      if (mounted) setState(() => disableFinishButton = true);
+      if (mounted) {
+        setState(() {
+          disableFinishButton = true;
+          state = _ServerAddState.checkingServerCredentials;
+        });
+      }
+      // TODO(bdlukaa): only allow numbers in the port fields
       final port = int.parse(portController.text.trim());
       final server = await API.instance.checkServerCredentials(
         Server(
@@ -704,9 +733,11 @@ class _ConfigureDVRServerScreenState extends State<ConfigureDVRServerScreen> {
 
       if (server.serverUUID != null && server.cookie != null) {
         widget.onServerChange(server);
+        state = _ServerAddState.gettingDevices;
         await ServersProvider.instance.add(server);
         widget.onNext();
       } else {
+        state = _ServerAddState.none;
         if (context.mounted) {
           showDialog(
             context: context,
@@ -801,7 +832,9 @@ class _AdditionalServerSettingsState extends State<AdditionalServerSettings> {
                 crossAxisAlignment: CrossAxisAlignment.start,
                 children: [
                   SizedBox(
-                    width: MediaQuery.sizeOf(context).width / 2.5,
+                    width: isDesktop
+                        ? MediaQuery.sizeOf(context).width / 2.5
+                        : null,
                     child: _buildCardAppBar(
                       title: loc.serverSettings,
                       description: loc.serverSettingsDescription,
@@ -986,85 +1019,160 @@ class LetsGoScreen extends StatelessWidget {
     final theme = Theme.of(context);
     final loc = AppLocalizations.of(context);
 
-    return PopScope(
-      canPop: false,
-      child: IntrinsicWidth(
-        child: Container(
-          margin: const EdgeInsetsDirectional.all(16.0),
-          constraints: BoxConstraints(
-            minWidth: MediaQuery.sizeOf(context).width / 2.5,
+    final addedCard = Card(
+      elevation: 4.0,
+      margin: const EdgeInsetsDirectional.only(bottom: 8.0),
+      color: Color.alphaBlend(
+        Colors.green.withOpacity(0.2),
+        theme.cardColor,
+      ),
+      child: Padding(
+        padding: const EdgeInsetsDirectional.all(16.0),
+        child: Row(children: [
+          Icon(
+            Icons.check,
+            color: Colors.green.shade400,
+          ),
+          const SizedBox(width: 16.0),
+          Expanded(
+            child: Text(loc.serverAdded),
           ),
+        ]),
+      ),
+    );
+
+    final tipsCard = Card(
+      elevation: 4.0,
+      margin: EdgeInsets.zero,
+      child: Padding(
+        padding: const EdgeInsetsDirectional.all(16.0),
+        child: SelectionArea(
           child: Column(
-            mainAxisAlignment: MainAxisAlignment.center,
-            crossAxisAlignment: CrossAxisAlignment.end,
+            crossAxisAlignment: CrossAxisAlignment.start,
             children: [
-              if (server != null)
-                Card(
-                  elevation: 4.0,
-                  margin: const EdgeInsetsDirectional.only(bottom: 8.0),
-                  color: Color.alphaBlend(
-                    Colors.green.withOpacity(0.2),
-                    theme.cardColor,
+              Text(
+                loc.letsGoDescription,
+                style: theme.textTheme.headlineMedium,
+              ),
+              ...[loc.tip0, loc.tip1, loc.tip2, loc.tip3].map((tip) {
+                return Padding(
+                  padding: const EdgeInsetsDirectional.only(
+                    top: 8.0,
                   ),
-                  child: Padding(
-                    padding: const EdgeInsetsDirectional.all(16.0),
-                    child: Row(children: [
-                      Icon(
-                        Icons.check,
-                        color: Colors.green.shade400,
-                      ),
-                      const SizedBox(width: 16.0),
+                  child: Row(
+                    crossAxisAlignment: CrossAxisAlignment.start,
+                    children: [
+                      const Text(' • '),
+                      const SizedBox(width: 4.0),
                       Expanded(
-                        child: Text(loc.serverAdded),
+                        child: Text(tip),
                       ),
-                    ]),
+                    ],
                   ),
+                );
+              }),
+            ],
+          ),
+        ),
+      ),
+    );
+
+    final finishButton = Align(
+      alignment: AlignmentDirectional.centerEnd,
+      child: FloatingActionButton.extended(
+        onPressed: onFinish,
+        label: Text(loc.finish.toUpperCase()),
+        icon: const Icon(Icons.check),
+      ),
+    );
+
+    return LayoutBuilder(builder: (context, consts) {
+      if (consts.maxWidth < kMobileBreakpoint.width) {
+        return PopScope(
+          canPop: false,
+          child: ListView(
+            padding: const EdgeInsetsDirectional.all(24.0),
+            children: [
+              SizedBox(
+                height: consts.maxHeight * 0.875,
+                child: Column(
+                  mainAxisAlignment: MainAxisAlignment.center,
+                  children: [
+                    if (server != null) addedCard,
+                    tipsCard,
+                    const SizedBox(height: 8.0),
+                    finishButton,
+                    const SizedBox(height: 12.0),
+                  ],
                 ),
+              ),
               Card(
-                elevation: 4.0,
-                margin: EdgeInsets.zero,
                 child: Padding(
-                  padding: const EdgeInsetsDirectional.all(16.0),
-                  child: SelectionArea(
-                    child: Column(
-                      crossAxisAlignment: CrossAxisAlignment.start,
-                      children: [
-                        Text(
-                          loc.letsGoDescription,
-                          style: theme.textTheme.headlineMedium,
-                        ),
-                        ...[loc.tip0, loc.tip1, loc.tip2, loc.tip3].map((tip) {
-                          return Padding(
-                            padding: const EdgeInsetsDirectional.only(
-                              top: 8.0,
-                            ),
-                            child: Row(
-                              crossAxisAlignment: CrossAxisAlignment.start,
-                              children: [
-                                const Text(' • '),
-                                const SizedBox(width: 4.0),
-                                Expanded(
-                                  child: Text(tip),
-                                ),
-                              ],
-                            ),
-                          );
-                        }),
-                      ],
-                    ),
+                  padding: const EdgeInsetsDirectional.symmetric(vertical: 8),
+                  child: Column(
+                    mainAxisSize: MainAxisSize.min,
+                    children: server!.devices.sorted().map((device) {
+                      return DesktopDeviceSelectorTile(
+                        device: device,
+                        selected: false,
+                        selectable: false,
+                      );
+                    }).toList(),
                   ),
                 ),
-              ),
-              const SizedBox(height: 8.0),
-              FloatingActionButton.extended(
-                onPressed: onFinish,
-                label: Text(loc.finish.toUpperCase()),
-                icon: const Icon(Icons.check),
-              ),
+              )
             ],
           ),
-        ),
-      ),
-    );
+        );
+      } else {
+        return PopScope(
+          canPop: false,
+          child: IntrinsicWidth(
+            child: Container(
+              margin: const EdgeInsetsDirectional.all(16.0),
+              constraints: BoxConstraints(
+                minWidth: MediaQuery.sizeOf(context).width / 2.5,
+              ),
+              child: Row(children: [
+                Expanded(
+                  child: Column(
+                    mainAxisAlignment: MainAxisAlignment.center,
+                    crossAxisAlignment: CrossAxisAlignment.end,
+                    children: [
+                      if (server != null) addedCard,
+                      tipsCard,
+                      const SizedBox(height: 8.0),
+                      finishButton,
+                    ],
+                  ),
+                ),
+                if (server != null && server!.devices.isNotEmpty) ...[
+                  const SizedBox(width: 16.0),
+                  SizedBox(
+                    width: kSidebarConstraints.maxWidth,
+                    child: Card(
+                      child: SingleChildScrollView(
+                        padding:
+                            const EdgeInsetsDirectional.symmetric(vertical: 8),
+                        child: Column(
+                          mainAxisSize: MainAxisSize.min,
+                          children: server!.devices.sorted().map((device) {
+                            return DesktopDeviceSelectorTile(
+                              device: device,
+                              selected: false,
+                              selectable: false,
+                            );
+                          }).toList(),
+                        ),
+                      ),
+                    ),
+                  ),
+                ]
+              ]),
+            ),
+          ),
+        );
+      }
+    });
   }
 }

From ca2517c4939caaf7291e5be54197831d4a684735 Mon Sep 17 00:00:00 2001
From: Bruno D'Luka <brunodlukaa@gmail.com>
Date: Mon, 29 Jan 2024 15:29:48 -0300
Subject: [PATCH 07/13] fix: Only allow numbers chars in the port field

---
 lib/widgets/servers/add_server.dart | 13 +++++++++----
 1 file changed, 9 insertions(+), 4 deletions(-)

diff --git a/lib/widgets/servers/add_server.dart b/lib/widgets/servers/add_server.dart
index 1a582f00..ebc319aa 100644
--- a/lib/widgets/servers/add_server.dart
+++ b/lib/widgets/servers/add_server.dart
@@ -424,6 +424,9 @@ class _ConfigureDVRServerScreenState extends State<ConfigureDVRServerScreen> {
         hintText: '$kDefaultPort',
         border: const OutlineInputBorder(),
       ),
+      onChanged: (value) {
+        portController.text = value.replaceAll(RegExp(r'[^0-9]'), '');
+      },
     );
 
     final rtspPortField = TextFormField(
@@ -445,6 +448,9 @@ class _ConfigureDVRServerScreenState extends State<ConfigureDVRServerScreen> {
         hintText: '$kDefaultRTSPPort',
         border: const OutlineInputBorder(),
       ),
+      onChanged: (value) {
+        rtspPortController.text = value.replaceAll(RegExp(r'[^0-9]'), '');
+      },
     );
 
     final nameField = TextFormField(
@@ -625,10 +631,10 @@ class _ConfigureDVRServerScreenState extends State<ConfigureDVRServerScreen> {
                     Row(mainAxisAlignment: MainAxisAlignment.end, children: [
                       if (disableFinishButton) ...[
                         const SizedBox(
-                          height: 18.0,
-                          width: 18.0,
+                          height: 16.0,
+                          width: 16.0,
                           child: CircularProgressIndicator.adaptive(
-                            strokeWidth: 2.0,
+                            strokeWidth: 1.5,
                           ),
                         ),
                         Padding(
@@ -716,7 +722,6 @@ class _ConfigureDVRServerScreenState extends State<ConfigureDVRServerScreen> {
           state = _ServerAddState.checkingServerCredentials;
         });
       }
-      // TODO(bdlukaa): only allow numbers in the port fields
       final port = int.parse(portController.text.trim());
       final server = await API.instance.checkServerCredentials(
         Server(

From 19ed92d19ac3165de1bd39c92709d3759695c7f8 Mon Sep 17 00:00:00 2001
From: Bruno D'Luka <brunodlukaa@gmail.com>
Date: Tue, 30 Jan 2024 10:46:16 -0300
Subject: [PATCH 08/13] fix(windows): window size

---
 lib/utils/window.dart   | 6 +++---
 windows/runner/main.cpp | 2 +-
 2 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/lib/utils/window.dart b/lib/utils/window.dart
index be294b1c..5c44109c 100644
--- a/lib/utils/window.dart
+++ b/lib/utils/window.dart
@@ -57,9 +57,9 @@ Future<void> configureWindow() async {
         windowButtonVisibility: true,
       ),
       () async {
-        // if ((isDesktopPlatform && Platform.isMacOS) || kDebugMode) {
-        //  await windowManager.setSize(kInitialWindowSize);
-        // }
+        if (kDebugMode) {
+          await windowManager.setSize(kInitialWindowSize);
+        }
         await windowManager.show();
       },
     );
diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp
index 78bd43ed..16705b49 100644
--- a/windows/runner/main.cpp
+++ b/windows/runner/main.cpp
@@ -26,7 +26,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
 
   FlutterWindow window(project);
   Win32Window::Point origin(10, 10);
-  Win32Window::Size size(900, 645);
+  Win32Window::Size size(1066, 645);
   if (!window.Create(L"Bluecherry", origin, size)) {
     return EXIT_FAILURE;
   }

From eb1623048b43554a1cd8344536d5f5a512b5b607 Mon Sep 17 00:00:00 2001
From: Bruno D'Luka <brunodlukaa@gmail.com>
Date: Tue, 30 Jan 2024 11:01:16 -0300
Subject: [PATCH 09/13] feat: Server devices list dialog

---
 lib/widgets/settings/mobile/settings.dart    |  1 +
 lib/widgets/settings/shared/server_tile.dart | 46 ++++++++++++++++++--
 2 files changed, 44 insertions(+), 3 deletions(-)

diff --git a/lib/widgets/settings/mobile/settings.dart b/lib/widgets/settings/mobile/settings.dart
index 82b8b360..0c9fc14d 100644
--- a/lib/widgets/settings/mobile/settings.dart
+++ b/lib/widgets/settings/mobile/settings.dart
@@ -27,6 +27,7 @@ import 'package:bluecherry_client/providers/update_provider.dart';
 import 'package:bluecherry_client/utils/constants.dart';
 import 'package:bluecherry_client/utils/extensions.dart';
 import 'package:bluecherry_client/utils/widgets/squared_icon_button.dart';
+import 'package:bluecherry_client/widgets/device_grid/device_grid.dart';
 import 'package:bluecherry_client/widgets/misc.dart';
 import 'package:bluecherry_client/widgets/servers/edit_server.dart';
 import 'package:bluecherry_client/widgets/servers/edit_server_settings.dart';
diff --git a/lib/widgets/settings/shared/server_tile.dart b/lib/widgets/settings/shared/server_tile.dart
index c43b02ec..174d6dc8 100644
--- a/lib/widgets/settings/shared/server_tile.dart
+++ b/lib/widgets/settings/shared/server_tile.dart
@@ -405,13 +405,53 @@ Future showServerMenu({
       ),
       const PopupMenuDivider(height: 1.0),
       PopupMenuItem(
-        child: Text(
-          server.online ? loc.refreshDevices : loc.refreshServer,
-        ),
+        child: Text(server.online ? loc.refreshDevices : loc.refreshServer),
         onTap: () async {
           servers.refreshDevices(ids: [server.id]);
         },
       ),
+      if (server.online)
+        PopupMenuItem(
+          child: const Text('View devices'),
+          onTap: () async {
+            showDialog(
+              context: context,
+              builder: (context) => DevicesListDialog(server: server),
+            );
+          },
+        ),
     ],
   );
 }
+
+class DevicesListDialog extends StatelessWidget {
+  final Server server;
+
+  const DevicesListDialog({super.key, required this.server});
+
+  @override
+  Widget build(BuildContext context) {
+    return AlertDialog(
+      title: Text('${server.name} devices'),
+      contentPadding: const EdgeInsetsDirectional.symmetric(
+        horizontal: 16.0,
+        vertical: 12.0,
+      ),
+      content: SizedBox(
+        width: kSidebarConstraints.maxWidth,
+        child: ListView.builder(
+          itemCount: server.devices.length,
+          shrinkWrap: true,
+          itemBuilder: (context, index) {
+            final device = server.devices[index];
+            return DesktopDeviceSelectorTile(
+              device: device,
+              selected: false,
+              selectable: false,
+            );
+          },
+        ),
+      ),
+    );
+  }
+}

From 932d468f7764e35091bd819581cc45c4d754a0d7 Mon Sep 17 00:00:00 2001
From: Bruno D'Luka <brunodlukaa@gmail.com>
Date: Tue, 30 Jan 2024 11:30:14 -0300
Subject: [PATCH 10/13] feat: Device info dialog

---
 .../desktop/desktop_device_grid.dart          |   4 +-
 .../device_grid/desktop/desktop_sidebar.dart  |  14 ++-
 .../desktop/device_info_dialog.dart           | 119 ++++++++++++++++++
 lib/widgets/device_grid/device_grid.dart      |   1 +
 4 files changed, 131 insertions(+), 7 deletions(-)
 create mode 100644 lib/widgets/device_grid/desktop/device_info_dialog.dart

diff --git a/lib/widgets/device_grid/desktop/desktop_device_grid.dart b/lib/widgets/device_grid/desktop/desktop_device_grid.dart
index c72e0bb2..a872c26f 100644
--- a/lib/widgets/device_grid/desktop/desktop_device_grid.dart
+++ b/lib/widgets/device_grid/desktop/desktop_device_grid.dart
@@ -91,9 +91,7 @@ class _DesktopDeviceGridState extends State<DesktopDeviceGrid> {
                 ),
                 child: Text(
                   '${view.currentLayout.devices.length}',
-                  style: TextStyle(
-                    color: theme.colorScheme.onPrimaryContainer,
-                  ),
+                  style: TextStyle(color: theme.colorScheme.onPrimaryContainer),
                 ),
               ),
             ]);
diff --git a/lib/widgets/device_grid/desktop/desktop_sidebar.dart b/lib/widgets/device_grid/desktop/desktop_sidebar.dart
index f93991a4..42b41a63 100644
--- a/lib/widgets/device_grid/desktop/desktop_sidebar.dart
+++ b/lib/widgets/device_grid/desktop/desktop_sidebar.dart
@@ -412,10 +412,7 @@ class _DesktopDeviceSelectorTileState extends State<DesktopDeviceSelectorTile> {
         offset.dx + size.width,
         offset.dy + size.height,
       ),
-      constraints: BoxConstraints(
-        maxWidth: size.width,
-        minWidth: size.width,
-      ),
+      constraints: BoxConstraints(maxWidth: size.width, minWidth: size.width),
       items: <PopupMenuEntry>[
         PopupMenuLabel(
           label: Padding(
@@ -471,6 +468,15 @@ class _DesktopDeviceSelectorTileState extends State<DesktopDeviceSelectorTile> {
               });
             },
           ),
+        const PopupMenuDivider(),
+        PopupMenuItem(
+          child: const Text('Device info'),
+          onTap: () async {
+            WidgetsBinding.instance.addPostFrameCallback((_) async {
+              await showDeviceInfoDialog(context, widget.device);
+            });
+          },
+        ),
       ],
     );
   }
diff --git a/lib/widgets/device_grid/desktop/device_info_dialog.dart b/lib/widgets/device_grid/desktop/device_info_dialog.dart
new file mode 100644
index 00000000..32090bdc
--- /dev/null
+++ b/lib/widgets/device_grid/desktop/device_info_dialog.dart
@@ -0,0 +1,119 @@
+/*
+ * 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 <http://www.gnu.org/licenses/>.
+ */
+
+import 'package:bluecherry_client/models/device.dart';
+import 'package:bluecherry_client/utils/theme.dart';
+import 'package:bluecherry_client/utils/widgets/squared_icon_button.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+Future<void> showDeviceInfoDialog(BuildContext context, Device device) async {
+  await showDialog(
+    context: context,
+    builder: (context) => DeviceInfoDialog(device: device),
+  );
+}
+
+class DeviceInfoDialog extends StatefulWidget {
+  final Device device;
+
+  const DeviceInfoDialog({super.key, required this.device});
+
+  @override
+  State<DeviceInfoDialog> createState() => _DeviceInfoDialogState();
+}
+
+class _DeviceInfoDialogState extends State<DeviceInfoDialog> {
+  bool _showStreamUrl = false;
+
+  @override
+  Widget build(BuildContext context) {
+    final theme = Theme.of(context);
+    final loc = AppLocalizations.of(context);
+    return AlertDialog(
+      title: Text(widget.device.name),
+      content: Column(
+        mainAxisSize: MainAxisSize.min,
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          _buildInfoTile(loc.serverName, widget.device.server.ip),
+          _buildInfoTile(
+            'Status',
+            widget.device.status ? loc.online : loc.offline,
+            TextStyle(
+              color: widget.device.status
+                  ? theme.extension<UnityColors>()!.successColor
+                  : theme.colorScheme.error,
+            ),
+          ),
+          _buildInfoTile('Uri', widget.device.uri),
+          _buildInfoTile(loc.resolution,
+              '${widget.device.resolutionX}x${widget.device.resolutionY}'),
+          _buildInfoTile('Has PTZ?', widget.device.hasPTZ ? loc.yes : loc.no),
+          _buildInfoTileWidget(
+            loc.streamURL,
+            Row(children: [
+              Text(
+                _showStreamUrl
+                    ? widget.device.streamURL
+                    : List.generate(widget.device.streamURL.length, (index) {
+                        return '•';
+                      }).join(),
+              ),
+              const SizedBox(width: 6.0),
+              SquaredIconButton(
+                onPressed: () =>
+                    setState(() => _showStreamUrl = !_showStreamUrl),
+                tooltip: _showStreamUrl ? 'Hide' : 'Show',
+                icon: Icon(
+                  _showStreamUrl ? Icons.visibility_off : Icons.visibility,
+                  size: 18.0,
+                ),
+              ),
+            ]),
+          ),
+        ],
+      ),
+    );
+  }
+
+  Widget _buildInfoTile(String title, String value, [TextStyle? valueStyle]) {
+    return _buildInfoTileWidget(title, Text(value, style: valueStyle));
+  }
+
+  Widget _buildInfoTileWidget(String title, Widget value) {
+    return Builder(builder: (context) {
+      final theme = Theme.of(context);
+      return IntrinsicHeight(
+        child: Row(children: [
+          SizedBox(
+            width: 100.0,
+            child: Text(
+              title,
+              style: theme.textTheme.labelLarge,
+              textAlign: TextAlign.end,
+            ),
+          ),
+          const VerticalDivider(),
+          Flexible(child: value),
+        ]),
+      );
+    });
+  }
+}
diff --git a/lib/widgets/device_grid/device_grid.dart b/lib/widgets/device_grid/device_grid.dart
index 6d4435f0..fbb4a2ad 100644
--- a/lib/widgets/device_grid/device_grid.dart
+++ b/lib/widgets/device_grid/device_grid.dart
@@ -38,6 +38,7 @@ import 'package:bluecherry_client/utils/video_player.dart';
 import 'package:bluecherry_client/utils/widgets/squared_icon_button.dart';
 import 'package:bluecherry_client/utils/window.dart';
 import 'package:bluecherry_client/widgets/collapsable_sidebar.dart';
+import 'package:bluecherry_client/widgets/device_grid/desktop/device_info_dialog.dart';
 import 'package:bluecherry_client/widgets/device_grid/desktop/external_stream.dart';
 import 'package:bluecherry_client/widgets/device_grid/desktop/layout_manager.dart';
 import 'package:bluecherry_client/widgets/device_grid/desktop/multicast_view.dart';

From 9797b34f5304a06b3acd6fd15964a9612ca4af2c Mon Sep 17 00:00:00 2001
From: Bruno D'Luka <brunodlukaa@gmail.com>
Date: Tue, 30 Jan 2024 11:49:17 -0300
Subject: [PATCH 11/13] feat: Localizations

---
 lib/l10n/app_en.arb                           | 22 +++++++++++++++++--
 lib/l10n/app_fr.arb                           | 20 ++++++++++++++++-
 lib/l10n/app_pl.arb                           | 20 ++++++++++++++++-
 lib/l10n/app_pt.arb                           | 22 +++++++++++++++++--
 .../device_grid/desktop/desktop_sidebar.dart  |  2 +-
 .../desktop/device_info_dialog.dart           |  9 ++++----
 .../device_grid/desktop/layout_manager.dart   |  6 ++---
 .../device_grid/video_status_label.dart       |  2 +-
 .../desktop/timeline_sidebar.dart             |  9 ++++----
 lib/widgets/servers/add_server.dart           |  5 +++--
 lib/widgets/settings/shared/server_tile.dart  |  5 +++--
 11 files changed, 99 insertions(+), 23 deletions(-)

diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index ff2256b7..83eded0c 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -15,10 +15,13 @@
   "savePassword": "Save password",
   "showPassword": "Show password",
   "hidePassword": "Hide password",
+  "hide": "Hide",
+  "show": "Show",
   "useDefault": "Use Default",
   "connect": "Connect",
   "connectAutomaticallyAtStartup": "Connect automatically at startup",
   "connectAutomaticallyAtStartupDescription": "If enabled, the server will be automatically connected when the app starts.",
+  "checkingServerCredentials": "Checking server credentials",
   "skip": "Skip",
   "cancel": "Cancel",
   "disabled": "Disabled",
@@ -89,6 +92,7 @@
   "reloadCamera": "Reload Camera",
   "selectACamera": "Select a camera",
   "switchCamera": "Switch camera",
+  "status": "Status",
   "online": "Online",
   "offline": "Offline",
   "live": "LIVE",
@@ -104,10 +108,12 @@
   "streamURL": "Stream URL",
   "streamURLRequired": "The stream URL is required",
   "streamURLNotValid": "The stream URL is not valid",
+  "uri": "URI",
   "eventBrowser": "Events History",
   "eventsTimeline": "Timeline of Events",
   "server": "Server",
   "device": "Device",
+  "deviceInfo": "Device info",
   "event": "Event",
   "duration": "Duration",
   "priority": "Priority",
@@ -165,7 +171,7 @@
   "no": "No",
   "about": "About",
   "versionText": "Copyright © 2022, Bluecherry LLC.\nAll rights reserved.",
-  "gettingDevices": "Getting devices...",
+  "gettingDevices": "Getting devices",
   "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",
@@ -183,6 +189,15 @@
   "configureServer": "Configure server",
   "refreshDevices": "Refresh devices",
   "refreshServer": "Refresh server",
+  "viewDevices": "View devices",
+  "serverDevices": "{server} devices",
+  "@serverDevices": {
+    "placeholders": {
+      "server": {
+        "type": "String"
+      }
+    }
+  },
   "refresh": "Refresh",
   "view": "View",
   "cameraRefreshPeriod": "Camera refresh period",
@@ -344,6 +359,7 @@
   "expand": "Expand",
   "more": "More",
   "@PTZ": {},
+  "isPtzSupported": "Supports PTZ?",
   "ptzSupported": "PTZ is supported",
   "enabledPTZ": "PTZ is enabled",
   "disabledPTZ": "PTZ is disabled",
@@ -508,5 +524,7 @@
   "serverHostnameExample": "https://my-server.bluecherry.app:7001",
   "rackName": "Rack name",
   "rackNameExample": "Lab 1",
-  "openServer": "Open server"
+  "openServer": "Open server",
+  "@SEARCH": {},
+  "disableSearch": "Disable search"
 }
\ No newline at end of file
diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb
index 8fa760a6..a7968ba6 100644
--- a/lib/l10n/app_fr.arb
+++ b/lib/l10n/app_fr.arb
@@ -15,10 +15,13 @@
   "savePassword": "Sauvegarder mot de passe",
   "showPassword": "Afficher mot de passe",
   "hidePassword": "Masquer mot de passe",
+  "hide": "Hide",
+  "show": "Show",
   "useDefault": "Par défaut",
   "connect": "Connecter",
   "connectAutomaticallyAtStartup": "Connecter automatiquement au démarrage",
   "connectAutomaticallyAtStartupDescription": "If enabled, the app will automatically connect to the server when it starts.",
+  "checkingServerCredentials": "Checking server credentials",
   "skip": "Sauter",
   "cancel": "Annuler",
   "disabled": "Disabled",
@@ -85,6 +88,7 @@
   "reloadCamera": "Recharger caméra",
   "selectACamera": "Sélectionner une caméra",
   "switchCamera": "Switch camera",
+  "status": "Status",
   "online": "En ligne",
   "offline": "Hors ligne",
   "live": "EN DIRECT",
@@ -100,10 +104,12 @@
   "streamURL": "Stream URL",
   "streamURLRequired": "The stream URL is required",
   "streamURLNotValid": "The stream URL is not valid",
+  "uri": "URI",
   "eventBrowser": "Navigateur d'événements",
   "eventsTimeline": "Ligne du temps",
   "server": "Serveur",
   "device": "Appareil",
+  "deviceInfo": "Device info",
   "event": "Évènement",
   "duration": "Durée",
   "priority": "Priorité",
@@ -173,6 +179,15 @@
   "configureServer": "Configurer le serveur",
   "refreshDevices": "Actualiser les appareils",
   "refreshServer": "Actualiser le serveur",
+  "viewDevices": "View devices",
+  "serverDevices": "{server} devices",
+  "@serverDevices": {
+    "placeholders": {
+      "server": {
+        "type": "String"
+      }
+    }
+  },
   "refresh": "Actualiser",
   "view": "Vue",
   "cameraRefreshPeriod": "Camera refresh period",
@@ -322,6 +337,7 @@
   "expand": "Développer",
   "more": "More",
   "@PTZ": {},
+  "isPtzSupported": "Supports PTZ?",
   "ptzSupported": "Support PTZ",
   "enabledPTZ": "PTZ est activé",
   "disabledPTZ": "PTZ est désactivé",
@@ -482,5 +498,7 @@
   "serverHostnameExample": "https://my-server.bluecherry.app:7001",
   "rackName": "Rack name",
   "rackNameExample": "Lab 1",
-  "openServer": "Open server"
+  "openServer": "Open server",
+  "@SEARCH": {},
+  "disableSearch": "Disable search"
 }
\ No newline at end of file
diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb
index adfe41a9..653a985d 100644
--- a/lib/l10n/app_pl.arb
+++ b/lib/l10n/app_pl.arb
@@ -15,10 +15,13 @@
   "savePassword": "Zapisz hasło",
   "showPassword": "Show password",
   "hidePassword": "Hide password",
+  "hide": "Hide",
+  "show": "Show",
   "useDefault": "Użyj wartości domyślnych",
   "connect": "Połącz",
   "connectAutomaticallyAtStartup": "Połącz automatycznie przy uruchomieniu",
   "connectAutomaticallyAtStartupDescription": "If enabled, the app will automatically connect to the server when it starts.",
+  "checkingServerCredentials": "Checking server credentials",
   "skip": "Pomiń",
   "cancel": "Anuluj",
   "disabled": "Disabled",
@@ -89,6 +92,7 @@
   "reloadCamera": "Odśwież kamerę",
   "selectACamera": "Wybierz kamerę",
   "switchCamera": "Przełącz kamerę",
+  "status": "Status",
   "online": "Online",
   "offline": "Offline",
   "live": "NA ŻYWO",
@@ -104,10 +108,12 @@
   "streamURL": "Stream URL",
   "streamURLRequired": "The stream URL is required",
   "streamURLNotValid": "The stream URL is not valid",
+  "uri": "URI",
   "eventBrowser": "Historia zdarzeń",
   "eventsTimeline": "Oś czasu zdarzeń",
   "server": "Serwer",
   "device": "Urządzenie",
+  "deviceInfo": "Device info",
   "event": "Zdarzenie",
   "duration": "Czas trwania",
   "priority": "Priorytet",
@@ -183,6 +189,15 @@
   "configureServer": "Konfiguracja serwera",
   "refreshDevices": "Odśwież urządzenia",
   "refreshServer": "Odśwież serwer",
+  "viewDevices": "View devices",
+  "serverDevices": "{server} devices",
+  "@serverDevices": {
+    "placeholders": {
+      "server": {
+        "type": "String"
+      }
+    }
+  },
   "refresh": "Odśwież",
   "view": "Widok",
   "cameraRefreshPeriod": "Camera refresh period",
@@ -344,6 +359,7 @@
   "expand": "Rozwiń",
   "more": "More",
   "@PTZ": {},
+  "isPtzSupported": "Supports PTZ?",
   "ptzSupported": "PTZ jest wspierane",
   "enabledPTZ": "PTZ jest włączone",
   "disabledPTZ": "PTZ jest wyłączone",
@@ -508,5 +524,7 @@
   "serverHostnameExample": "https://my-server.bluecherry.app:7001",
   "rackName": "Rack name",
   "rackNameExample": "Lab 1",
-  "openServer": "Open server"
+  "openServer": "Open server",
+  "@SEARCH": {},
+  "disableSearch": "Disable search"
 }
\ No newline at end of file
diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb
index 177fab8c..ff3de7a6 100644
--- a/lib/l10n/app_pt.arb
+++ b/lib/l10n/app_pt.arb
@@ -15,10 +15,13 @@
   "savePassword": "Salvar senha",
   "showPassword": "Mostrar senha",
   "hidePassword": "Ocultar senha",
+  "hide": "Esconder",
+  "show": "Mostrar",
   "useDefault": "Usar Padrão",
   "connect": "Conectar",
   "connectAutomaticallyAtStartup": "Conectar automaticamente ao iniciar",
   "connectAutomaticallyAtStartupDescription": "Se ativado, o servidor será conectado automaticamente quando o aplicativo for iniciado.",
+  "checkingServerCredentials": "Verificando credenciais",
   "skip": "Pular",
   "cancel": "Cancelar",
   "disabled": "Desativado",
@@ -89,6 +92,7 @@
   "reloadCamera": "Recarregar Câmera",
   "selectACamera": "Selecione uma câmera",
   "switchCamera": "Trocar câmera",
+  "status": "Status",
   "online": "Online",
   "offline": "Offline",
   "live": "AO VIVO",
@@ -104,10 +108,12 @@
   "streamURL": "URL da Transmissão",
   "streamURLRequired": "A URL da transmissão é obrigatória",
   "streamURLNotValid": "A url não é válida",
+  "uri": "URI",
   "eventBrowser": "Histórico de eventos",
   "eventsTimeline": "Linha do tempo de eventos",
   "server": "Servidor",
   "device": "Dispositivo",
+  "deviceInfo": "Informações do dispositivo",
   "event": "Evento",
   "duration": "Duração",
   "priority": "Prioridade",
@@ -183,6 +189,15 @@
   "configureServer": "Configurar servidor",
   "refreshDevices": "Recarregar dispositivos",
   "refreshServer": "Recarregar servidor",
+  "viewDevices": "Ver dispositivos",
+  "serverDevices": "Dispositivos de {server}",
+  "@serverDevices": {
+    "placeholders": {
+      "server": {
+        "type": "String"
+      }
+    }
+  },
   "refresh": "Recarregar",
   "view": "Layouts",
   "cameraRefreshPeriod": "Intervalo para recarregar cameras",
@@ -344,7 +359,8 @@
   "expand": "Expandir",
   "more": "Mais",
   "@PTZ": {},
-  "ptzSupported": "PTZ é suportado",
+  "isPtzSupported": "Possui PTZ?",
+  "ptzSupported": "Possui PTZ",
   "enabledPTZ": "PTZ está ativado",
   "disabledPTZ": "PTZ está desativado",
   "move": "Movimento",
@@ -508,5 +524,7 @@
   "serverHostnameExample": "https://servidor.bluecherry.app:7001",
   "rackName": "Nome do rack",
   "rackNameExample": "Lab 1",
-  "openServer": "Abrir servidor"
+  "openServer": "Abrir servidor",
+  "@SEARCH": {},
+  "disableSearch": "Desativar pesquisa"
 }
\ No newline at end of file
diff --git a/lib/widgets/device_grid/desktop/desktop_sidebar.dart b/lib/widgets/device_grid/desktop/desktop_sidebar.dart
index 42b41a63..37d0dc1d 100644
--- a/lib/widgets/device_grid/desktop/desktop_sidebar.dart
+++ b/lib/widgets/device_grid/desktop/desktop_sidebar.dart
@@ -470,7 +470,7 @@ class _DesktopDeviceSelectorTileState extends State<DesktopDeviceSelectorTile> {
           ),
         const PopupMenuDivider(),
         PopupMenuItem(
-          child: const Text('Device info'),
+          child: Text(loc.deviceInfo),
           onTap: () async {
             WidgetsBinding.instance.addPostFrameCallback((_) async {
               await showDeviceInfoDialog(context, widget.device);
diff --git a/lib/widgets/device_grid/desktop/device_info_dialog.dart b/lib/widgets/device_grid/desktop/device_info_dialog.dart
index 32090bdc..b002dc54 100644
--- a/lib/widgets/device_grid/desktop/device_info_dialog.dart
+++ b/lib/widgets/device_grid/desktop/device_info_dialog.dart
@@ -54,7 +54,7 @@ class _DeviceInfoDialogState extends State<DeviceInfoDialog> {
         children: [
           _buildInfoTile(loc.serverName, widget.device.server.ip),
           _buildInfoTile(
-            'Status',
+            loc.status,
             widget.device.status ? loc.online : loc.offline,
             TextStyle(
               color: widget.device.status
@@ -62,10 +62,11 @@ class _DeviceInfoDialogState extends State<DeviceInfoDialog> {
                   : theme.colorScheme.error,
             ),
           ),
-          _buildInfoTile('Uri', widget.device.uri),
+          _buildInfoTile(loc.uri, widget.device.uri),
           _buildInfoTile(loc.resolution,
               '${widget.device.resolutionX}x${widget.device.resolutionY}'),
-          _buildInfoTile('Has PTZ?', widget.device.hasPTZ ? loc.yes : loc.no),
+          _buildInfoTile(
+              loc.isPtzSupported, widget.device.hasPTZ ? loc.yes : loc.no),
           _buildInfoTileWidget(
             loc.streamURL,
             Row(children: [
@@ -80,7 +81,7 @@ class _DeviceInfoDialogState extends State<DeviceInfoDialog> {
               SquaredIconButton(
                 onPressed: () =>
                     setState(() => _showStreamUrl = !_showStreamUrl),
-                tooltip: _showStreamUrl ? 'Hide' : 'Show',
+                tooltip: _showStreamUrl ? loc.hide : loc.show,
                 icon: Icon(
                   _showStreamUrl ? Icons.visibility_off : Icons.visibility,
                   size: 18.0,
diff --git a/lib/widgets/device_grid/desktop/layout_manager.dart b/lib/widgets/device_grid/desktop/layout_manager.dart
index ee50e70c..7e2e2d33 100644
--- a/lib/widgets/device_grid/desktop/layout_manager.dart
+++ b/lib/widgets/device_grid/desktop/layout_manager.dart
@@ -175,13 +175,13 @@ class _LayoutManagerState extends State<LayoutManager> {
               child: TextField(
                 controller: searchController,
                 focusNode: searchFocusNode,
-                decoration: const InputDecoration(
-                  hintText: 'Search',
+                decoration: InputDecoration(
+                  hintText: MaterialLocalizations.of(context).searchFieldLabel,
                   isDense: true,
                   border: InputBorder.none,
                   focusedBorder: InputBorder.none,
                   enabledBorder: InputBorder.none,
-                  contentPadding: EdgeInsetsDirectional.symmetric(
+                  contentPadding: const EdgeInsetsDirectional.symmetric(
                     horizontal: 8.0,
                     vertical: 4.0,
                   ),
diff --git a/lib/widgets/device_grid/video_status_label.dart b/lib/widgets/device_grid/video_status_label.dart
index 0b26a0d8..c708c889 100644
--- a/lib/widgets/device_grid/video_status_label.dart
+++ b/lib/widgets/device_grid/video_status_label.dart
@@ -241,7 +241,7 @@ class _DeviceVideoInfo extends StatelessWidget {
         server,
         _buildTextSpan(
           context,
-          title: loc.ptzSupported,
+          title: loc.isPtzSupported,
           data: device.hasPTZ ? loc.yes : loc.no,
         ),
         _buildTextSpan(
diff --git a/lib/widgets/events_timeline/desktop/timeline_sidebar.dart b/lib/widgets/events_timeline/desktop/timeline_sidebar.dart
index 9c8fff06..8d44ed21 100644
--- a/lib/widgets/events_timeline/desktop/timeline_sidebar.dart
+++ b/lib/widgets/events_timeline/desktop/timeline_sidebar.dart
@@ -171,13 +171,14 @@ class EventsSearchButton extends StatelessWidget {
 
   @override
   Widget build(BuildContext context) {
+    final loc = AppLocalizations.of(context);
     return SquaredIconButton(
       icon: Icon(
         searchVisible ? Icons.search_off : Icons.search,
         size: iconSize,
       ),
       tooltip: searchVisible
-          ? 'Disable search'
+          ? loc.disableSearch
           : MaterialLocalizations.of(context).searchFieldLabel,
       onPressed: onPressed,
     );
@@ -212,13 +213,13 @@ class EventsSearchBar extends StatelessWidget {
             child: TextField(
               controller: searchController,
               focusNode: searchFocusNode,
-              decoration: const InputDecoration(
-                hintText: 'Search',
+              decoration: InputDecoration(
+                hintText: MaterialLocalizations.of(context).searchFieldLabel,
                 isDense: true,
                 border: InputBorder.none,
                 focusedBorder: InputBorder.none,
                 enabledBorder: InputBorder.none,
-                contentPadding: EdgeInsetsDirectional.symmetric(
+                contentPadding: const EdgeInsetsDirectional.symmetric(
                   horizontal: 8.0,
                   vertical: 4.0,
                 ),
diff --git a/lib/widgets/servers/add_server.dart b/lib/widgets/servers/add_server.dart
index ebc319aa..d61069f6 100644
--- a/lib/widgets/servers/add_server.dart
+++ b/lib/widgets/servers/add_server.dart
@@ -643,8 +643,9 @@ class _ConfigureDVRServerScreenState extends State<ConfigureDVRServerScreen> {
                           ),
                           child: Text(switch (state) {
                             _ServerAddState.checkingServerCredentials =>
-                              'Checking server credentials',
-                            _ServerAddState.gettingDevices => 'Getting devices',
+                              loc.checkingServerCredentials,
+                            _ServerAddState.gettingDevices =>
+                              loc.gettingDevices,
                             _ServerAddState.none => '',
                           }),
                         ),
diff --git a/lib/widgets/settings/shared/server_tile.dart b/lib/widgets/settings/shared/server_tile.dart
index 174d6dc8..8a1f0f3e 100644
--- a/lib/widgets/settings/shared/server_tile.dart
+++ b/lib/widgets/settings/shared/server_tile.dart
@@ -412,7 +412,7 @@ Future showServerMenu({
       ),
       if (server.online)
         PopupMenuItem(
-          child: const Text('View devices'),
+          child: Text(loc.viewDevices),
           onTap: () async {
             showDialog(
               context: context,
@@ -431,8 +431,9 @@ class DevicesListDialog extends StatelessWidget {
 
   @override
   Widget build(BuildContext context) {
+    final loc = AppLocalizations.of(context);
     return AlertDialog(
-      title: Text('${server.name} devices'),
+      title: Text(loc.serverDevices(server.name)),
       contentPadding: const EdgeInsetsDirectional.symmetric(
         horizontal: 16.0,
         vertical: 12.0,

From a8854dabaead64e907e2486916798b86eefb54a3 Mon Sep 17 00:00:00 2001
From: Bruno D'Luka <brunodlukaa@gmail.com>
Date: Tue, 30 Jan 2024 11:52:08 -0300
Subject: [PATCH 12/13] feat: Show devices info dialog on offline devices

---
 .../device_grid/desktop/desktop_sidebar.dart  | 73 +++++++++----------
 1 file changed, 36 insertions(+), 37 deletions(-)

diff --git a/lib/widgets/device_grid/desktop/desktop_sidebar.dart b/lib/widgets/device_grid/desktop/desktop_sidebar.dart
index 37d0dc1d..653a8499 100644
--- a/lib/widgets/device_grid/desktop/desktop_sidebar.dart
+++ b/lib/widgets/device_grid/desktop/desktop_sidebar.dart
@@ -311,15 +311,12 @@ class _DesktopDeviceSelectorTileState extends State<DesktopDeviceSelectorTile> {
               },
         child: MouseRegion(
           onEnter: (_) {
-            if (!widget.selectable) return;
             if (mounted) setState(() => hovering = true);
           },
           onHover: (_) {
-            if (!widget.selectable) return;
             if (mounted && !hovering) setState(() => hovering = true);
           },
           onExit: (_) {
-            if (!widget.selectable) return;
             if (mounted) setState(() => hovering = false);
           },
           child: SizedBox(
@@ -361,7 +358,7 @@ class _DesktopDeviceSelectorTileState extends State<DesktopDeviceSelectorTile> {
                   ),
                 ),
               ),
-              if ((isMobile || hovering) && widget.device.status)
+              if (isMobile || hovering)
                 Tooltip(
                   message: loc.cameraOptions,
                   preferBelow: false,
@@ -369,7 +366,7 @@ class _DesktopDeviceSelectorTileState extends State<DesktopDeviceSelectorTile> {
                     borderRadius: BorderRadius.circular(4.0),
                     onTap: widget.device.status
                         ? () => _displayOptions(context)
-                        : null,
+                        : () => showDeviceInfoDialog(context, widget.device),
                     child: Icon(moreIconData, size: 20.0),
                   ),
                 ),
@@ -426,40 +423,42 @@ class _DesktopDeviceSelectorTileState extends State<DesktopDeviceSelectorTile> {
           ),
         ),
         const PopupMenuDivider(),
-        PopupMenuItem(
-          child: Text(
-            widget.selected ? loc.removeFromView : loc.addToView,
-          ),
-          onTap: () {
-            if (widget.selected) {
-              view.remove(widget.device);
-            } else {
-              view.add(widget.device);
-            }
-          },
-        ),
-        PopupMenuItem(
-          child: Text(loc.showFullscreenCamera),
-          onTap: () async {
-            WidgetsBinding.instance.addPostFrameCallback((_) async {
-              var player = UnityPlayers.players[widget.device.uuid];
-              final isLocalController = player == null;
-              if (isLocalController) {
-                player = UnityPlayers.forDevice(widget.device);
+        if (widget.selectable)
+          PopupMenuItem(
+            child: Text(
+              widget.selected ? loc.removeFromView : loc.addToView,
+            ),
+            onTap: () {
+              if (widget.selected) {
+                view.remove(widget.device);
+              } else {
+                view.add(widget.device);
               }
+            },
+          ),
+        if (widget.device.status)
+          PopupMenuItem(
+            child: Text(loc.showFullscreenCamera),
+            onTap: () async {
+              WidgetsBinding.instance.addPostFrameCallback((_) async {
+                var player = UnityPlayers.players[widget.device.uuid];
+                final isLocalController = player == null;
+                if (isLocalController) {
+                  player = UnityPlayers.forDevice(widget.device);
+                }
 
-              await Navigator.of(context).pushNamed(
-                '/fullscreen',
-                arguments: {
-                  'device': widget.device,
-                  'player': player,
-                },
-              );
-              if (isLocalController) await player.dispose();
-            });
-          },
-        ),
-        if (isDesktop)
+                await Navigator.of(context).pushNamed(
+                  '/fullscreen',
+                  arguments: {
+                    'device': widget.device,
+                    'player': player,
+                  },
+                );
+                if (isLocalController) await player.dispose();
+              });
+            },
+          ),
+        if (isDesktop && widget.device.status)
           PopupMenuItem(
             child: Text(loc.openInANewWindow),
             onTap: () async {

From b8c47d518718efe4de8a9c97f70ff37bb02f42e6 Mon Sep 17 00:00:00 2001
From: Bruno D'Luka <brunodlukaa@gmail.com>
Date: Tue, 30 Jan 2024 18:28:32 -0300
Subject: [PATCH 13/13] chore: Rename EventsSearchButton to SearchToggleButton;
 Rename EventsSearchBar to ToggleSearcbBar

---
 .../device_grid/desktop/layout_manager.dart   | 36 ++------
 lib/widgets/direct_camera.dart                |  6 +-
 lib/widgets/events/events_screen.dart         |  6 +-
 lib/widgets/events/filter.dart                |  6 +-
 .../desktop/timeline_sidebar.dart             | 85 +-----------------
 lib/widgets/home.dart                         |  4 +-
 lib/widgets/search.dart                       | 87 +++++++++++++++++++
 7 files changed, 109 insertions(+), 121 deletions(-)
 create mode 100644 lib/widgets/search.dart

diff --git a/lib/widgets/device_grid/desktop/layout_manager.dart b/lib/widgets/device_grid/desktop/layout_manager.dart
index 7e2e2d33..16fc682e 100644
--- a/lib/widgets/device_grid/desktop/layout_manager.dart
+++ b/lib/widgets/device_grid/desktop/layout_manager.dart
@@ -26,9 +26,9 @@ import 'package:bluecherry_client/providers/settings_provider.dart';
 import 'package:bluecherry_client/utils/methods.dart';
 import 'package:bluecherry_client/utils/widgets/squared_icon_button.dart';
 import 'package:bluecherry_client/utils/window.dart';
-import 'package:bluecherry_client/widgets/events_timeline/desktop/timeline_sidebar.dart';
 import 'package:bluecherry_client/widgets/hover_button.dart';
 import 'package:bluecherry_client/widgets/misc.dart';
+import 'package:bluecherry_client/widgets/search.dart';
 import 'package:file_picker/file_picker.dart';
 import 'package:flutter/gestures.dart';
 import 'package:flutter/material.dart';
@@ -109,7 +109,7 @@ class _LayoutManagerState extends State<LayoutManager> {
                 widget.collapseButton,
                 const SizedBox(width: 5.0),
                 Expanded(child: Text(loc.view, maxLines: 1)),
-                EventsSearchButton(
+                SearchToggleButton(
                   searchVisible: searchVisible,
                   iconSize: 18.0,
                   onPressed: () {
@@ -164,32 +164,12 @@ class _LayoutManagerState extends State<LayoutManager> {
             },
           ),
         ),
-        AnimatedSlide(
-          offset: searchVisible ? Offset.zero : const Offset(0, 1),
-          duration: kThemeChangeDuration,
-          curve: Curves.easeInOut,
-          child: Column(children: [
-            const Divider(height: 1.0),
-            Padding(
-              padding: const EdgeInsets.all(8.0),
-              child: TextField(
-                controller: searchController,
-                focusNode: searchFocusNode,
-                decoration: InputDecoration(
-                  hintText: MaterialLocalizations.of(context).searchFieldLabel,
-                  isDense: true,
-                  border: InputBorder.none,
-                  focusedBorder: InputBorder.none,
-                  enabledBorder: InputBorder.none,
-                  contentPadding: const EdgeInsetsDirectional.symmetric(
-                    horizontal: 8.0,
-                    vertical: 4.0,
-                  ),
-                ),
-                onChanged: widget.onSearchChanged,
-              ),
-            )
-          ]),
+        ToggleSearchBar(
+          searchVisible: searchVisible,
+          searchController: searchController,
+          searchFocusNode: searchFocusNode,
+          onSearchChanged: widget.onSearchChanged,
+          showBottomDivider: false,
         ),
         const Divider(height: 1.0),
       ]),
diff --git a/lib/widgets/direct_camera.dart b/lib/widgets/direct_camera.dart
index 5acad4fd..8941dd0b 100644
--- a/lib/widgets/direct_camera.dart
+++ b/lib/widgets/direct_camera.dart
@@ -25,8 +25,8 @@ import 'package:bluecherry_client/utils/extensions.dart';
 import 'package:bluecherry_client/utils/theme.dart';
 import 'package:bluecherry_client/utils/video_player.dart';
 import 'package:bluecherry_client/widgets/error_warning.dart';
-import 'package:bluecherry_client/widgets/events_timeline/desktop/timeline_sidebar.dart';
 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:provider/provider.dart';
@@ -70,7 +70,7 @@ class DirectCameraScreenState extends State<DirectCameraScreen> {
     final loc = AppLocalizations.of(context);
     final hasDrawer = Scaffold.hasDrawer(context);
 
-    final searchBar = EventsSearchBar(
+    final searchBar = ToggleSearchBar(
       searchController: _searchController,
       searchFocusNode: _searchFocusNode,
       searchVisible: _searchVisible,
@@ -88,7 +88,7 @@ class DirectCameraScreenState extends State<DirectCameraScreen> {
             actions: [
               Padding(
                 padding: const EdgeInsetsDirectional.only(end: 12.0),
-                child: EventsSearchButton(
+                child: SearchToggleButton(
                   searchVisible: _searchVisible,
                   onPressed: toggleSearch,
                 ),
diff --git a/lib/widgets/events/events_screen.dart b/lib/widgets/events/events_screen.dart
index e4e8e60a..04029959 100644
--- a/lib/widgets/events/events_screen.dart
+++ b/lib/widgets/events/events_screen.dart
@@ -37,8 +37,8 @@ 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/events/filter.dart';
-import 'package:bluecherry_client/widgets/events_timeline/desktop/timeline_sidebar.dart';
 import 'package:bluecherry_client/widgets/misc.dart';
+import 'package:bluecherry_client/widgets/search.dart';
 import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
@@ -216,7 +216,7 @@ class EventsScreenState<T extends StatefulWidget> extends State<T> {
                       children: [
                         Padding(
                           padding: const EdgeInsetsDirectional.only(end: 6.0),
-                          child: EventsSearchButton(
+                          child: SearchToggleButton(
                             searchVisible: searchVisible,
                             onPressed: () {
                               setState(() => searchVisible = !searchVisible);
@@ -232,7 +232,7 @@ class EventsScreenState<T extends StatefulWidget> extends State<T> {
                       ],
                     ),
                   ),
-                  EventsSearchBar(
+                  ToggleSearchBar(
                     searchVisible: searchVisible,
                     searchController: searchController,
                     searchFocusNode: searchFocusNode,
diff --git a/lib/widgets/events/filter.dart b/lib/widgets/events/filter.dart
index 0054c10b..23c07ecd 100644
--- a/lib/widgets/events/filter.dart
+++ b/lib/widgets/events/filter.dart
@@ -21,8 +21,8 @@ import 'package:bluecherry_client/providers/server_provider.dart';
 import 'package:bluecherry_client/utils/extensions.dart';
 import 'package:bluecherry_client/utils/widgets/tree_view.dart';
 import 'package:bluecherry_client/widgets/events/events_screen.dart';
-import 'package:bluecherry_client/widgets/events_timeline/desktop/timeline_sidebar.dart';
 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:provider/provider.dart';
@@ -207,12 +207,12 @@ class _MobileFilterSheetState extends State<MobileFilterSheet> {
       SubHeader(
         loc.servers,
         height: 38.0,
-        trailing: EventsSearchButton(
+        trailing: SearchToggleButton(
           searchVisible: searchVisible,
           onPressed: () => setState(() => searchVisible = !searchVisible),
         ),
       ),
-      EventsSearchBar(
+      ToggleSearchBar(
         searchVisible: searchVisible,
         searchController: searchController,
         searchFocusNode: searchFocusNode,
diff --git a/lib/widgets/events_timeline/desktop/timeline_sidebar.dart b/lib/widgets/events_timeline/desktop/timeline_sidebar.dart
index 8d44ed21..2e8ced7b 100644
--- a/lib/widgets/events_timeline/desktop/timeline_sidebar.dart
+++ b/lib/widgets/events_timeline/desktop/timeline_sidebar.dart
@@ -18,11 +18,11 @@
  */
 
 import 'package:auto_size_text/auto_size_text.dart';
-import 'package:bluecherry_client/utils/widgets/squared_icon_button.dart';
 import 'package:bluecherry_client/widgets/collapsable_sidebar.dart';
 import 'package:bluecherry_client/widgets/events/filter.dart';
 import 'package:bluecherry_client/widgets/events_timeline/events_playback.dart';
 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';
@@ -86,7 +86,7 @@ class _TimelineSidebarState extends State<TimelineSidebar> {
                 mainAxisAlignment: MainAxisAlignment.end,
                 crossAxisAlignment: CrossAxisAlignment.end,
                 children: [
-                  EventsSearchButton(
+                  SearchToggleButton(
                     searchVisible: searchVisible,
                     iconSize: 22.0,
                     onPressed: () {
@@ -101,7 +101,7 @@ class _TimelineSidebarState extends State<TimelineSidebar> {
               ),
               padding: const EdgeInsetsDirectional.only(start: 16.0, end: 4.0),
             ),
-            EventsSearchBar(
+            ToggleSearchBar(
               searchVisible: searchVisible,
               searchController: searchController,
               searchFocusNode: searchFocusNode,
@@ -155,82 +155,3 @@ class _TimelineSidebarState extends State<TimelineSidebar> {
     );
   }
 }
-
-class EventsSearchButton extends StatelessWidget {
-  final bool searchVisible;
-  final VoidCallback onPressed;
-
-  final double iconSize;
-
-  const EventsSearchButton({
-    super.key,
-    required this.searchVisible,
-    required this.onPressed,
-    this.iconSize = 20.0,
-  });
-
-  @override
-  Widget build(BuildContext context) {
-    final loc = AppLocalizations.of(context);
-    return SquaredIconButton(
-      icon: Icon(
-        searchVisible ? Icons.search_off : Icons.search,
-        size: iconSize,
-      ),
-      tooltip: searchVisible
-          ? loc.disableSearch
-          : MaterialLocalizations.of(context).searchFieldLabel,
-      onPressed: onPressed,
-    );
-  }
-}
-
-class EventsSearchBar extends StatelessWidget {
-  final bool searchVisible;
-  final TextEditingController searchController;
-  final FocusNode searchFocusNode;
-  final ValueChanged<String> onSearchChanged;
-
-  const EventsSearchBar({
-    super.key,
-    required this.searchVisible,
-    required this.searchController,
-    required this.searchFocusNode,
-    required this.onSearchChanged,
-  });
-
-  @override
-  Widget build(BuildContext context) {
-    return AnimatedSize(
-      duration: kThemeChangeDuration,
-      curve: Curves.easeInOut,
-      child: Builder(builder: (context) {
-        if (!searchVisible) return const SizedBox.shrink();
-        return Column(children: [
-          const Divider(height: 1.0),
-          Padding(
-            padding: const EdgeInsets.all(8.0),
-            child: TextField(
-              controller: searchController,
-              focusNode: searchFocusNode,
-              decoration: InputDecoration(
-                hintText: MaterialLocalizations.of(context).searchFieldLabel,
-                isDense: true,
-                border: InputBorder.none,
-                focusedBorder: InputBorder.none,
-                enabledBorder: InputBorder.none,
-                contentPadding: const EdgeInsetsDirectional.symmetric(
-                  horizontal: 8.0,
-                  vertical: 4.0,
-                ),
-              ),
-              onChanged: onSearchChanged,
-            ),
-          ),
-          const Divider(height: 1.0),
-          const SizedBox(height: 8.0),
-        ]);
-      }),
-    );
-  }
-}
diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart
index 950f5cb7..8ebe6298 100644
--- a/lib/widgets/home.dart
+++ b/lib/widgets/home.dart
@@ -26,8 +26,8 @@ import 'package:bluecherry_client/widgets/device_grid/device_grid.dart';
 import 'package:bluecherry_client/widgets/direct_camera.dart';
 import 'package:bluecherry_client/widgets/downloads_manager.dart';
 import 'package:bluecherry_client/widgets/events/events_screen.dart';
-import 'package:bluecherry_client/widgets/events_timeline/desktop/timeline_sidebar.dart';
 import 'package:bluecherry_client/widgets/events_timeline/events_playback.dart';
+import 'package:bluecherry_client/widgets/search.dart';
 import 'package:bluecherry_client/widgets/servers/add_server.dart';
 import 'package:bluecherry_client/widgets/settings/settings.dart';
 import 'package:flutter/material.dart';
@@ -339,7 +339,7 @@ class _MobileHomeState extends State<Home> {
           ),
         ),
         if (directCameraKey.currentState != null)
-          EventsSearchButton(
+          SearchToggleButton(
             searchVisible: directCameraKey.currentState!.searchVisible,
             onPressed: () {
               directCameraKey.currentState!.toggleSearch();
diff --git a/lib/widgets/search.dart b/lib/widgets/search.dart
new file mode 100644
index 00000000..db9d2e9e
--- /dev/null
+++ b/lib/widgets/search.dart
@@ -0,0 +1,87 @@
+import 'package:bluecherry_client/utils/widgets/squared_icon_button.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+class SearchToggleButton extends StatelessWidget {
+  final bool searchVisible;
+  final VoidCallback onPressed;
+
+  final double iconSize;
+
+  const SearchToggleButton({
+    super.key,
+    required this.searchVisible,
+    required this.onPressed,
+    this.iconSize = 20.0,
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    final loc = AppLocalizations.of(context);
+    return SquaredIconButton(
+      icon: Icon(
+        searchVisible ? Icons.search_off : Icons.search,
+        size: iconSize,
+      ),
+      tooltip: searchVisible
+          ? loc.disableSearch
+          : MaterialLocalizations.of(context).searchFieldLabel,
+      onPressed: onPressed,
+    );
+  }
+}
+
+class ToggleSearchBar extends StatelessWidget {
+  final bool searchVisible;
+  final TextEditingController searchController;
+  final FocusNode searchFocusNode;
+  final ValueChanged<String> onSearchChanged;
+
+  final bool showBottomDivider;
+
+  const ToggleSearchBar({
+    super.key,
+    required this.searchVisible,
+    required this.searchController,
+    required this.searchFocusNode,
+    required this.onSearchChanged,
+    this.showBottomDivider = true,
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    return AnimatedSize(
+      duration: kThemeChangeDuration,
+      curve: Curves.easeInOut,
+      child: Builder(builder: (context) {
+        if (!searchVisible) return const SizedBox.shrink();
+        return Column(children: [
+          const Divider(height: 1.0),
+          Padding(
+            padding: const EdgeInsets.all(8.0),
+            child: TextField(
+              controller: searchController,
+              focusNode: searchFocusNode,
+              decoration: InputDecoration(
+                hintText: MaterialLocalizations.of(context).searchFieldLabel,
+                isDense: true,
+                border: InputBorder.none,
+                focusedBorder: InputBorder.none,
+                enabledBorder: InputBorder.none,
+                contentPadding: const EdgeInsetsDirectional.symmetric(
+                  horizontal: 8.0,
+                  vertical: 4.0,
+                ),
+              ),
+              onChanged: onSearchChanged,
+            ),
+          ),
+          if (showBottomDivider) ...[
+            const Divider(height: 1.0),
+            const SizedBox(height: 8.0),
+          ],
+        ]);
+      }),
+    );
+  }
+}