From d4be38daec28114b4f77c8c3e79fae3c1ad4f7cc Mon Sep 17 00:00:00 2001 From: lxBlazarxl Date: Thu, 17 Sep 2026 13:43:49 +0530 Subject: [PATCH 1/8] fix(service_qbittorrent): disable filter end drawer on settings tab --- .../lib/src/qbittorrent_home.dart | 4 +- .../test/qbit_filter_button_test.dart | 42 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/services/service_qbittorrent/lib/src/qbittorrent_home.dart b/services/service_qbittorrent/lib/src/qbittorrent_home.dart index 73da2eba..f360024e 100644 --- a/services/service_qbittorrent/lib/src/qbittorrent_home.dart +++ b/services/service_qbittorrent/lib/src/qbittorrent_home.dart @@ -38,6 +38,7 @@ class QbittorrentHome extends ConsumerWidget { final int currentIndex = ref.watch(qbitActiveTabBarIndexProvider(instance)); final bool isNavbarVisible = ref.watch(qbitBottomNavVisibleProvider(instance)); + final bool isHomeTab = currentIndex == 0; final List tabs = [ _TorrentsTab(instance: instance), @@ -48,7 +49,8 @@ class QbittorrentHome extends ConsumerWidget { drawerEdgeDragWidth: drawer != null ? MediaQuery.sizeOf(context).width * 0.15 : null, drawer: drawer, - endDrawer: QbittorrentFilterDrawer(instance: instance), + endDrawer: isHomeTab ? QbittorrentFilterDrawer(instance: instance) : null, + endDrawerEnableOpenDragGesture: isHomeTab, body: NotificationListener( onNotification: (ScrollNotification notification) { if (notification.metrics.axis == Axis.vertical) { diff --git a/services/service_qbittorrent/test/qbit_filter_button_test.dart b/services/service_qbittorrent/test/qbit_filter_button_test.dart index 42be5cb1..120e6a6b 100644 --- a/services/service_qbittorrent/test/qbit_filter_button_test.dart +++ b/services/service_qbittorrent/test/qbit_filter_button_test.dart @@ -122,6 +122,48 @@ void main() { 'which owns no end drawer, and the tap does nothing', ); }); + + testWidgets( + 'end drawer and swipe gesture are active on home tab, disabled on settings tab', + (WidgetTester tester) async { + final ProviderContainer container = ProviderContainer( + overrides: [ + qbitRawTorrentsProvider(_instance) + .overrideWith((Ref ref) async => const []), + qbitTransferProvider(_instance) + .overrideWith((Ref ref) async => const QbitTransferInfo()), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: AtriumTheme.light(null), + home: const QbittorrentHome(instance: _instance), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + // On home view (tab 0) + final Scaffold homeScaffold = + tester.widget(find.byType(Scaffold).first); + expect(homeScaffold.endDrawer, isNotNull); + expect(homeScaffold.endDrawerEnableOpenDragGesture, isTrue); + + // Switch to settings tab (tab 1) + container.read(qbitActiveTabBarIndexProvider(_instance).notifier).state = 1; + await tester.pump(); + await tester.pump(); + + final Scaffold settingsScaffold = + tester.widget(find.byType(Scaffold).first); + expect(settingsScaffold.endDrawer, isNull); + expect(settingsScaffold.endDrawerEnableOpenDragGesture, isFalse); + }); } const Instance _instance = Instance( From e26c3c7943b9fcc19ffa1efb10c254327198de1a Mon Sep 17 00:00:00 2001 From: lxBlazarxl Date: Thu, 17 Sep 2026 14:02:50 +0530 Subject: [PATCH 2/8] feat(service_qbittorrent): add execution logs tab and API integration --- .../lib/service_qbittorrent.dart | 2 + .../lib/src/models/qbit_log_entry.dart | 78 ++++ .../lib/src/qbittorrent_client.dart | 32 ++ .../lib/src/qbittorrent_home.dart | 7 + .../lib/src/qbittorrent_logs_tab.dart | 340 ++++++++++++++++++ .../lib/src/qbittorrent_providers.dart | 14 + .../test/qbit_filter_button_test.dart | 16 +- .../test/qbit_logs_test.dart | 209 +++++++++++ 8 files changed, 696 insertions(+), 2 deletions(-) create mode 100644 services/service_qbittorrent/lib/src/models/qbit_log_entry.dart create mode 100644 services/service_qbittorrent/lib/src/qbittorrent_logs_tab.dart create mode 100644 services/service_qbittorrent/test/qbit_logs_test.dart diff --git a/services/service_qbittorrent/lib/service_qbittorrent.dart b/services/service_qbittorrent/lib/service_qbittorrent.dart index c86efc7d..0249b175 100644 --- a/services/service_qbittorrent/lib/service_qbittorrent.dart +++ b/services/service_qbittorrent/lib/service_qbittorrent.dart @@ -7,10 +7,12 @@ library; export 'src/add_torrent_sheet.dart'; export 'src/models/qbit_detail.dart'; +export 'src/models/qbit_log_entry.dart'; export 'src/models/qbit_torrent.dart'; export 'src/models/qbit_transfer_info.dart'; export 'src/qbittorrent_client.dart'; export 'src/qbittorrent_filter_drawer.dart'; export 'src/qbittorrent_home.dart'; +export 'src/qbittorrent_logs_tab.dart'; export 'src/qbittorrent_providers.dart'; export 'src/torrent_detail_screen.dart'; diff --git a/services/service_qbittorrent/lib/src/models/qbit_log_entry.dart b/services/service_qbittorrent/lib/src/models/qbit_log_entry.dart new file mode 100644 index 00000000..4b3f0f78 --- /dev/null +++ b/services/service_qbittorrent/lib/src/models/qbit_log_entry.dart @@ -0,0 +1,78 @@ +/// Severity levels for qBittorrent log entries. +enum QbitLogLevel { + normal('Normal'), + info('Info'), + warning('Warning'), + critical('Critical'); + + const QbitLogLevel(this.label); + final String label; + + static QbitLogLevel fromType(int type) { + return switch (type) { + 1 => QbitLogLevel.normal, + 2 => QbitLogLevel.info, + 4 => QbitLogLevel.warning, + 8 => QbitLogLevel.critical, + _ => QbitLogLevel.normal, + }; + } +} + +/// A single application log message from `GET /api/v2/log/main`. +class QbitLogEntry { + const QbitLogEntry({ + required this.id, + required this.message, + required this.timestamp, + required this.type, + }); + + /// The unique ID of the message. + final int id; + + /// The content of the log entry. + final String message; + + /// Timestamp (epoch milliseconds, or seconds on some older versions). + final int timestamp; + + /// Message type: 1 = normal, 2 = info, 4 = warning, 8 = critical. + final int type; + + /// Parsed severity level. + QbitLogLevel get level => QbitLogLevel.fromType(type); + + /// Normalized [DateTime] from [timestamp]. + DateTime get dateTime { + if (timestamp > 100000000000) { + return DateTime.fromMillisecondsSinceEpoch(timestamp); + } + return DateTime.fromMillisecondsSinceEpoch(timestamp * 1000); + } + + /// Formatted `HH:mm:ss` local time string. + String get timeText { + final DateTime local = dateTime.toLocal(); + final String h = local.hour.toString().padLeft(2, '0'); + final String m = local.minute.toString().padLeft(2, '0'); + final String s = local.second.toString().padLeft(2, '0'); + return '$h:$m:$s'; + } + + factory QbitLogEntry.fromJson(Map json) { + return QbitLogEntry( + id: json['id'] as int? ?? 0, + message: json['message'] as String? ?? '', + timestamp: json['timestamp'] as int? ?? 0, + type: json['type'] as int? ?? 1, + ); + } + + Map toJson() => { + 'id': id, + 'message': message, + 'timestamp': timestamp, + 'type': type, + }; +} diff --git a/services/service_qbittorrent/lib/src/qbittorrent_client.dart b/services/service_qbittorrent/lib/src/qbittorrent_client.dart index b99ab610..2de182a0 100644 --- a/services/service_qbittorrent/lib/src/qbittorrent_client.dart +++ b/services/service_qbittorrent/lib/src/qbittorrent_client.dart @@ -9,6 +9,7 @@ import 'package:dio/io.dart'; import 'package:dio_cookie_manager/dio_cookie_manager.dart'; import 'models/qbit_detail.dart'; +import 'models/qbit_log_entry.dart'; import 'models/qbit_torrent.dart'; import 'models/qbit_transfer_info.dart'; @@ -675,6 +676,37 @@ class QbittorrentClient { } }); + /// Retrieves application logs (`GET /api/v2/log/main`). + Future> getLogs({ + bool normal = true, + bool info = true, + bool warning = true, + bool critical = true, + int lastKnownId = -1, + }) => + _guarded(() async { + final Response resp = await _dio.get( + 'api/v2/log/main', + queryParameters: { + 'normal': normal, + 'info': info, + 'warning': warning, + 'critical': critical, + if (lastKnownId >= 0) 'last_known_id': lastKnownId, + }, + ); + final dynamic data = resp.data; + if (data is! List) return const []; + return data + .map( + (dynamic item) => item is Map + ? QbitLogEntry.fromJson(Map.from(item)) + : null, + ) + .whereType() + .toList(); + }); + /// Ensures a session exists, runs [call], and re-logins once on a 403. Future _guarded(Future Function() call) async { // API-key auth is stateless: no login round-trip, and a 403 means the key diff --git a/services/service_qbittorrent/lib/src/qbittorrent_home.dart b/services/service_qbittorrent/lib/src/qbittorrent_home.dart index f360024e..5bd3ab14 100644 --- a/services/service_qbittorrent/lib/src/qbittorrent_home.dart +++ b/services/service_qbittorrent/lib/src/qbittorrent_home.dart @@ -15,6 +15,7 @@ import 'models/qbit_transfer_info.dart'; import 'qbittorrent_action_utils.dart'; import 'qbittorrent_client.dart'; import 'qbittorrent_filter_drawer.dart'; +import 'qbittorrent_logs_tab.dart'; import 'qbittorrent_providers.dart'; import 'qbittorrent_settings_tab.dart'; import 'torrent_detail_screen.dart'; @@ -42,6 +43,7 @@ class QbittorrentHome extends ConsumerWidget { final List tabs = [ _TorrentsTab(instance: instance), + QbittorrentLogsTab(instance: instance), QbittorrentSettingsTab(instance: instance), ]; @@ -164,6 +166,11 @@ class QbittorrentHome extends ConsumerWidget { selectedIcon: Icon(Icons.home), label: 'Home', ), + NavigationDestination( + icon: Icon(Icons.article_outlined), + selectedIcon: Icon(Icons.article), + label: 'Logs', + ), NavigationDestination( icon: Icon(Icons.settings_outlined), selectedIcon: Icon(Icons.settings), diff --git a/services/service_qbittorrent/lib/src/qbittorrent_logs_tab.dart b/services/service_qbittorrent/lib/src/qbittorrent_logs_tab.dart new file mode 100644 index 00000000..f8d7d62c --- /dev/null +++ b/services/service_qbittorrent/lib/src/qbittorrent_logs_tab.dart @@ -0,0 +1,340 @@ +import 'package:core_models/core_models.dart'; +import 'package:core_ui/core_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'qbittorrent_providers.dart'; + +/// The Logs tab for qBittorrent displaying execution logs from `/api/v2/log/main`. +class QbittorrentLogsTab extends ConsumerStatefulWidget { + const QbittorrentLogsTab({required this.instance, super.key}); + + final Instance instance; + + @override + ConsumerState createState() => _QbittorrentLogsTabState(); +} + +class _QbittorrentLogsTabState extends ConsumerState { + final ScrollController _scrollController = ScrollController(); + final TextEditingController _searchController = TextEditingController(); + bool _isSearching = false; + String _searchQuery = ''; + QbitLogLevel? _selectedLevel; + + @override + void initState() { + super.initState(); + _searchController.addListener(() { + final String query = _searchController.text.trim().toLowerCase(); + if (query != _searchQuery) { + setState(() => _searchQuery = query); + } + }); + } + + @override + void dispose() { + _scrollController.dispose(); + _searchController.dispose(); + super.dispose(); + } + + void _scrollToTop() { + if (_scrollController.hasClients) { + _scrollController.animateTo( + 0.0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + } + + void _copyAllLogs(List logs) { + if (logs.isEmpty) return; + final String text = logs + .map( + (QbitLogEntry e) => + '[${e.timeText}] [${e.level.label.toUpperCase()}] ${e.message}', + ) + .join('\n'); + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Copied ${logs.length} log ${logs.length == 1 ? "entry" : "entries"} to clipboard', + ), + duration: const Duration(seconds: 2), + ), + ); + } + + void _copyLogEntry(QbitLogEntry entry) { + Clipboard.setData( + ClipboardData( + text: + '[${entry.timeText}] [${entry.level.label.toUpperCase()}] ${entry.message}', + ), + ); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Log entry copied to clipboard'), + duration: Duration(seconds: 1), + ), + ); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme cs = theme.colorScheme; + + // Listen to scroll to top signal from bottom nav tap (index 1 is Logs) + ref.listen( + qbitHomeScrollToTopProvider((widget.instance, 1)), + (_, __) => _scrollToTop(), + ); + + final AsyncValue> logsAsync = + ref.watch(qbitLogsProvider(widget.instance)); + + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.menu), + onPressed: () => Scaffold.of(context).openDrawer(), + ), + title: _isSearching + ? TextField( + controller: _searchController, + autofocus: true, + style: theme.textTheme.titleMedium, + decoration: InputDecoration( + hintText: 'Search logs...', + border: InputBorder.none, + hintStyle: theme.textTheme.titleMedium + ?.copyWith(color: cs.onSurfaceVariant), + ), + ) + : Text('${widget.instance.name} Logs'), + actions: [ + if (_isSearching) + IconButton( + icon: const Icon(Icons.close), + tooltip: 'Close search', + onPressed: () { + setState(() { + _isSearching = false; + _searchController.clear(); + _searchQuery = ''; + }); + }, + ) + else ...[ + IconButton( + icon: const Icon(Icons.search), + tooltip: 'Search logs', + onPressed: () => setState(() => _isSearching = true), + ), + IconButton( + icon: const Icon(Icons.copy_all_outlined), + tooltip: 'Copy all logs', + onPressed: () { + final List? currentLogs = logsAsync.value; + if (currentLogs != null) { + final List filtered = _filterLogs(currentLogs); + _copyAllLogs(filtered); + } + }, + ), + IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'Refresh', + onPressed: () => ref.invalidate(qbitLogsProvider(widget.instance)), + ), + ], + const SizedBox(width: Insets.xs), + ], + ), + body: Column( + children: [ + _buildFilterChips(cs), + const Divider(height: 1), + Expanded( + child: AsyncValueView>( + value: logsAsync, + onRetry: () => ref.invalidate(qbitLogsProvider(widget.instance)), + data: (List allLogs) { + final List filtered = _filterLogs(allLogs); + + if (filtered.isEmpty) { + return EmptyView( + icon: Icons.article_outlined, + title: allLogs.isEmpty + ? 'No logs available' + : 'No matching logs', + message: allLogs.isEmpty + ? 'qBittorrent has not reported any log entries yet.' + : 'Try changing your search query or level filter.', + ); + } + + // Show newest logs at top by reversing the list + final List reversed = filtered.reversed.toList(); + + return EasyRefresh( + header: const ClassicHeader( + dragText: 'Pull to refresh', + armedText: 'Release ready', + readyText: 'Refreshing...', + processingText: 'Refreshing...', + processedText: 'Succeeded', + failedText: 'Failed', + messageText: 'Last updated at %T', + ), + onRefresh: () async { + ref.invalidate(qbitLogsProvider(widget.instance)); + await ref.read(qbitLogsProvider(widget.instance).future); + }, + child: ListView.separated( + controller: _scrollController, + padding: const EdgeInsets.symmetric( + horizontal: Insets.md, + vertical: Insets.sm, + ), + itemCount: reversed.length, + separatorBuilder: (_, __) => Divider( + height: 1, + color: cs.outlineVariant.withAlpha(50), + ), + itemBuilder: (BuildContext context, int index) { + final QbitLogEntry entry = reversed[index]; + return _LogEntryTile( + entry: entry, + onTap: () => _copyLogEntry(entry), + ); + }, + ), + ); + }, + ), + ), + ], + ), + ); + } + + List _filterLogs(List logs) { + return logs.where((QbitLogEntry e) { + if (_selectedLevel != null && e.level != _selectedLevel) { + return false; + } + if (_searchQuery.isNotEmpty) { + return e.message.toLowerCase().contains(_searchQuery); + } + return true; + }).toList(); + } + + Widget _buildFilterChips(ColorScheme cs) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: Insets.md, + vertical: Insets.xs, + ), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: Insets.xs), + child: FilterChip( + selected: _selectedLevel == null, + label: const Text('All'), + onSelected: (_) => setState(() => _selectedLevel = null), + ), + ), + for (final QbitLogLevel level in QbitLogLevel.values) + Padding( + padding: const EdgeInsets.only(right: Insets.xs), + child: FilterChip( + selected: _selectedLevel == level, + label: Text(level.label), + onSelected: (bool selected) { + setState(() => _selectedLevel = selected ? level : null); + }, + ), + ), + ], + ), + ); + } +} + +class _LogEntryTile extends StatelessWidget { + const _LogEntryTile({required this.entry, required this.onTap}); + + final QbitLogEntry entry; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme cs = theme.colorScheme; + + final Color badgeColor = switch (entry.level) { + QbitLogLevel.critical => cs.error, + QbitLogLevel.warning => cs.secondary, + QbitLogLevel.info => cs.tertiary, + QbitLogLevel.normal => cs.primary, + }; + + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 58, + child: Text( + entry.timeText, + style: theme.textTheme.labelSmall?.copyWith( + fontFamily: 'monospace', + color: cs.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: Insets.xs), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: badgeColor.withAlpha(30), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + entry.level.label.toUpperCase(), + style: theme.textTheme.labelSmall?.copyWith( + color: badgeColor, + fontWeight: FontWeight.bold, + fontSize: 10, + ), + ), + ), + const SizedBox(width: Insets.sm), + Expanded( + child: Text( + entry.message, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/services/service_qbittorrent/lib/src/qbittorrent_providers.dart b/services/service_qbittorrent/lib/src/qbittorrent_providers.dart index 001ffe74..34e225b8 100644 --- a/services/service_qbittorrent/lib/src/qbittorrent_providers.dart +++ b/services/service_qbittorrent/lib/src/qbittorrent_providers.dart @@ -5,10 +5,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/legacy.dart'; import 'models/qbit_detail.dart'; +import 'models/qbit_log_entry.dart'; import 'models/qbit_torrent.dart'; import 'models/qbit_transfer_info.dart'; import 'qbittorrent_client.dart'; +export 'models/qbit_log_entry.dart'; + /// How often list-level data (torrents, global speeds) refreshes while a /// qBittorrent screen is visible. qBit's own web UI polls at 1.5s; 3s is a /// good mobile compromise. @@ -598,3 +601,14 @@ final qbitNetworkInterfaceAddressesProvider = await ref.watch(qbittorrentClientProvider(instance).future); return client.getNetworkInterfaceAddresses(iface: iface); }); + +/// qBittorrent main log messages provider. +final qbitLogsProvider = FutureProvider.family + .autoDispose, Instance>(( + Ref ref, + Instance instance, +) async { + final QbittorrentClient client = + await ref.watch(qbittorrentClientProvider(instance).future); + return client.getLogs(); +}); diff --git a/services/service_qbittorrent/test/qbit_filter_button_test.dart b/services/service_qbittorrent/test/qbit_filter_button_test.dart index 120e6a6b..4b3da1ba 100644 --- a/services/service_qbittorrent/test/qbit_filter_button_test.dart +++ b/services/service_qbittorrent/test/qbit_filter_button_test.dart @@ -124,7 +124,7 @@ void main() { }); testWidgets( - 'end drawer and swipe gesture are active on home tab, disabled on settings tab', + 'end drawer and swipe gesture are active on home tab, disabled on logs and settings tabs', (WidgetTester tester) async { final ProviderContainer container = ProviderContainer( overrides: [ @@ -132,6 +132,8 @@ void main() { .overrideWith((Ref ref) async => const []), qbitTransferProvider(_instance) .overrideWith((Ref ref) async => const QbitTransferInfo()), + qbitLogsProvider(_instance) + .overrideWith((Ref ref) async => const []), ], ); addTearDown(container.dispose); @@ -154,11 +156,21 @@ void main() { expect(homeScaffold.endDrawer, isNotNull); expect(homeScaffold.endDrawerEnableOpenDragGesture, isTrue); - // Switch to settings tab (tab 1) + // Switch to logs tab (tab 1) container.read(qbitActiveTabBarIndexProvider(_instance).notifier).state = 1; await tester.pump(); await tester.pump(); + final Scaffold logsScaffold = + tester.widget(find.byType(Scaffold).first); + expect(logsScaffold.endDrawer, isNull); + expect(logsScaffold.endDrawerEnableOpenDragGesture, isFalse); + + // Switch to settings tab (tab 2) + container.read(qbitActiveTabBarIndexProvider(_instance).notifier).state = 2; + await tester.pump(); + await tester.pump(); + final Scaffold settingsScaffold = tester.widget(find.byType(Scaffold).first); expect(settingsScaffold.endDrawer, isNull); diff --git a/services/service_qbittorrent/test/qbit_logs_test.dart b/services/service_qbittorrent/test/qbit_logs_test.dart new file mode 100644 index 00000000..7ac58525 --- /dev/null +++ b/services/service_qbittorrent/test/qbit_logs_test.dart @@ -0,0 +1,209 @@ +import 'package:core_models/core_models.dart'; +import 'package:core_ui/core_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:service_qbittorrent/service_qbittorrent.dart'; + +void main() { + group('QbitLogEntry', () { + test('parses json with milliseconds timestamp and computes level', () { + final Map json = { + 'id': 10, + 'message': 'qBittorrent v5.0 started', + 'timestamp': 1603884800000, + 'type': 1, + }; + + final QbitLogEntry entry = QbitLogEntry.fromJson(json); + expect(entry.id, 10); + expect(entry.message, 'qBittorrent v5.0 started'); + expect(entry.timestamp, 1603884800000); + expect(entry.type, 1); + expect(entry.level, QbitLogLevel.normal); + expect(entry.dateTime.millisecondsSinceEpoch, 1603884800000); + expect(entry.timeText, isNotEmpty); + expect(entry.toJson(), json); + }); + + test('handles seconds timestamp and all severity types', () { + final QbitLogEntry normal = QbitLogEntry.fromJson(const { + 'id': 1, + 'message': 'normal msg', + 'timestamp': 1603884800, + 'type': 1, + }); + final QbitLogEntry info = QbitLogEntry.fromJson(const { + 'id': 2, + 'message': 'info msg', + 'timestamp': 1603884800, + 'type': 2, + }); + final QbitLogEntry warning = QbitLogEntry.fromJson(const { + 'id': 3, + 'message': 'warning msg', + 'timestamp': 1603884800, + 'type': 4, + }); + final QbitLogEntry critical = + QbitLogEntry.fromJson(const { + 'id': 4, + 'message': 'critical msg', + 'timestamp': 1603884800, + 'type': 8, + }); + + expect(normal.level, QbitLogLevel.normal); + expect(info.level, QbitLogLevel.info); + expect(warning.level, QbitLogLevel.warning); + expect(critical.level, QbitLogLevel.critical); + expect(normal.dateTime.millisecondsSinceEpoch, 1603884800000); + }); + }); + + group('QbittorrentLogsTab', () { + const Instance instance = Instance( + id: 'qbit-test', + name: 'My qBittorrent', + kind: ServiceKind.qbittorrent, + localUrl: 'http://localhost:8080', + externalUrl: '', + urlMode: UrlMode.auto, + auth: InstanceAuth.apiKey(apiKey: 'k'), + ); + + final List sampleLogs = [ + const QbitLogEntry( + id: 1, + message: 'System initialized successfully', + timestamp: 1603884800000, + type: 1, + ), + const QbitLogEntry( + id: 2, + message: 'UPnP port mapping failed', + timestamp: 1603884810000, + type: 4, + ), + const QbitLogEntry( + id: 3, + message: 'Fatal error: disk space exhausted', + timestamp: 1603884820000, + type: 8, + ), + ]; + + testWidgets('renders logs with time, level badge, and messages', + (WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + qbitLogsProvider(instance) + .overrideWith((Ref ref) async => sampleLogs), + ], + child: MaterialApp( + theme: AtriumTheme.light(null), + home: const QbittorrentLogsTab(instance: instance), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('My qBittorrent Logs'), findsOneWidget); + expect(find.text('System initialized successfully'), findsOneWidget); + expect(find.text('UPnP port mapping failed'), findsOneWidget); + expect(find.text('Fatal error: disk space exhausted'), findsOneWidget); + expect(find.text('NORMAL'), findsOneWidget); + expect(find.text('WARNING'), findsOneWidget); + expect(find.text('CRITICAL'), findsOneWidget); + }); + + testWidgets('filters logs by level chip', (WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + qbitLogsProvider(instance) + .overrideWith((Ref ref) async => sampleLogs), + ], + child: MaterialApp( + theme: AtriumTheme.light(null), + home: const QbittorrentLogsTab(instance: instance), + ), + ), + ); + await tester.pumpAndSettle(); + + // Tap 'Warning' chip + await tester.tap(find.widgetWithText(FilterChip, 'Warning')); + await tester.pumpAndSettle(); + + expect(find.text('UPnP port mapping failed'), findsOneWidget); + expect(find.text('System initialized successfully'), findsNothing); + expect(find.text('Fatal error: disk space exhausted'), findsNothing); + + // Tap 'All' chip to reset + await tester.tap(find.widgetWithText(FilterChip, 'All')); + await tester.pumpAndSettle(); + + expect(find.text('System initialized successfully'), findsOneWidget); + expect(find.text('UPnP port mapping failed'), findsOneWidget); + expect(find.text('Fatal error: disk space exhausted'), findsOneWidget); + }); + + testWidgets('filters logs by search query', (WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + qbitLogsProvider(instance) + .overrideWith((Ref ref) async => sampleLogs), + ], + child: MaterialApp( + theme: AtriumTheme.light(null), + home: const QbittorrentLogsTab(instance: instance), + ), + ), + ); + await tester.pumpAndSettle(); + + // Tap search icon + await tester.tap(find.byTooltip('Search logs')); + await tester.pumpAndSettle(); + + // Type search query + await tester.enterText(find.byType(TextField), 'disk'); + await tester.pumpAndSettle(); + + expect(find.text('Fatal error: disk space exhausted'), findsOneWidget); + expect(find.text('System initialized successfully'), findsNothing); + expect(find.text('UPnP port mapping failed'), findsNothing); + }); + + testWidgets('shows empty state when no logs match', + (WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + qbitLogsProvider(instance) + .overrideWith((Ref ref) async => sampleLogs), + ], + child: MaterialApp( + theme: AtriumTheme.light(null), + home: const QbittorrentLogsTab(instance: instance), + ), + ), + ); + await tester.pumpAndSettle(); + + // Tap search icon + await tester.tap(find.byTooltip('Search logs')); + await tester.pumpAndSettle(); + + // Type search query that matches nothing + await tester.enterText(find.byType(TextField), 'nonexistent query'); + await tester.pumpAndSettle(); + + expect(find.text('No matching logs'), findsOneWidget); + }); + }); +} From 04e0c154f9f3644ab29ed31a3c711d1dab2f9727 Mon Sep 17 00:00:00 2001 From: lxBlazarxl Date: Thu, 17 Sep 2026 14:09:12 +0530 Subject: [PATCH 3/8] feat(service_qbittorrent): place logs tab after settings tab --- .../lib/src/qbittorrent_home.dart | 12 ++++++------ .../lib/src/qbittorrent_logs_tab.dart | 4 ++-- .../test/qbit_filter_button_test.dart | 16 ++++++++-------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/services/service_qbittorrent/lib/src/qbittorrent_home.dart b/services/service_qbittorrent/lib/src/qbittorrent_home.dart index 5bd3ab14..1ef84f2f 100644 --- a/services/service_qbittorrent/lib/src/qbittorrent_home.dart +++ b/services/service_qbittorrent/lib/src/qbittorrent_home.dart @@ -43,8 +43,8 @@ class QbittorrentHome extends ConsumerWidget { final List tabs = [ _TorrentsTab(instance: instance), - QbittorrentLogsTab(instance: instance), QbittorrentSettingsTab(instance: instance), + QbittorrentLogsTab(instance: instance), ]; return Scaffold( @@ -166,16 +166,16 @@ class QbittorrentHome extends ConsumerWidget { selectedIcon: Icon(Icons.home), label: 'Home', ), - NavigationDestination( - icon: Icon(Icons.article_outlined), - selectedIcon: Icon(Icons.article), - label: 'Logs', - ), NavigationDestination( icon: Icon(Icons.settings_outlined), selectedIcon: Icon(Icons.settings), label: 'Settings', ), + NavigationDestination( + icon: Icon(Icons.article_outlined), + selectedIcon: Icon(Icons.article), + label: 'Logs', + ), ], ), ), diff --git a/services/service_qbittorrent/lib/src/qbittorrent_logs_tab.dart b/services/service_qbittorrent/lib/src/qbittorrent_logs_tab.dart index f8d7d62c..d4bb178c 100644 --- a/services/service_qbittorrent/lib/src/qbittorrent_logs_tab.dart +++ b/services/service_qbittorrent/lib/src/qbittorrent_logs_tab.dart @@ -90,9 +90,9 @@ class _QbittorrentLogsTabState extends ConsumerState { final ThemeData theme = Theme.of(context); final ColorScheme cs = theme.colorScheme; - // Listen to scroll to top signal from bottom nav tap (index 1 is Logs) + // Listen to scroll to top signal from bottom nav tap (index 2 is Logs) ref.listen( - qbitHomeScrollToTopProvider((widget.instance, 1)), + qbitHomeScrollToTopProvider((widget.instance, 2)), (_, __) => _scrollToTop(), ); diff --git a/services/service_qbittorrent/test/qbit_filter_button_test.dart b/services/service_qbittorrent/test/qbit_filter_button_test.dart index 4b3da1ba..bfb9ae75 100644 --- a/services/service_qbittorrent/test/qbit_filter_button_test.dart +++ b/services/service_qbittorrent/test/qbit_filter_button_test.dart @@ -156,25 +156,25 @@ void main() { expect(homeScaffold.endDrawer, isNotNull); expect(homeScaffold.endDrawerEnableOpenDragGesture, isTrue); - // Switch to logs tab (tab 1) + // Switch to settings tab (tab 1) container.read(qbitActiveTabBarIndexProvider(_instance).notifier).state = 1; await tester.pump(); await tester.pump(); - final Scaffold logsScaffold = + final Scaffold settingsScaffold = tester.widget(find.byType(Scaffold).first); - expect(logsScaffold.endDrawer, isNull); - expect(logsScaffold.endDrawerEnableOpenDragGesture, isFalse); + expect(settingsScaffold.endDrawer, isNull); + expect(settingsScaffold.endDrawerEnableOpenDragGesture, isFalse); - // Switch to settings tab (tab 2) + // Switch to logs tab (tab 2) container.read(qbitActiveTabBarIndexProvider(_instance).notifier).state = 2; await tester.pump(); await tester.pump(); - final Scaffold settingsScaffold = + final Scaffold logsScaffold = tester.widget(find.byType(Scaffold).first); - expect(settingsScaffold.endDrawer, isNull); - expect(settingsScaffold.endDrawerEnableOpenDragGesture, isFalse); + expect(logsScaffold.endDrawer, isNull); + expect(logsScaffold.endDrawerEnableOpenDragGesture, isFalse); }); } From a95137795db84cdc71903647cbab567f368c5dfd Mon Sep 17 00:00:00 2001 From: lxBlazarxl Date: Thu, 17 Sep 2026 14:16:48 +0530 Subject: [PATCH 4/8] feat(service_qbittorrent): style log items with torrent card aesthetics --- .../lib/src/qbittorrent_logs_tab.dart | 213 +++++++++++++----- 1 file changed, 156 insertions(+), 57 deletions(-) diff --git a/services/service_qbittorrent/lib/src/qbittorrent_logs_tab.dart b/services/service_qbittorrent/lib/src/qbittorrent_logs_tab.dart index d4bb178c..bc2c6087 100644 --- a/services/service_qbittorrent/lib/src/qbittorrent_logs_tab.dart +++ b/services/service_qbittorrent/lib/src/qbittorrent_logs_tab.dart @@ -197,20 +197,16 @@ class _QbittorrentLogsTabState extends ConsumerState { ref.invalidate(qbitLogsProvider(widget.instance)); await ref.read(qbitLogsProvider(widget.instance).future); }, - child: ListView.separated( + child: ListView.builder( controller: _scrollController, - padding: const EdgeInsets.symmetric( - horizontal: Insets.md, - vertical: Insets.sm, + padding: const EdgeInsets.only( + top: Insets.xs, + bottom: 80, ), itemCount: reversed.length, - separatorBuilder: (_, __) => Divider( - height: 1, - color: cs.outlineVariant.withAlpha(50), - ), itemBuilder: (BuildContext context, int index) { final QbitLogEntry entry = reversed[index]; - return _LogEntryTile( + return _LogCard( entry: entry, onTap: () => _copyLogEntry(entry), ); @@ -271,8 +267,83 @@ class _QbittorrentLogsTabState extends ConsumerState { } } -class _LogEntryTile extends StatelessWidget { - const _LogEntryTile({required this.entry, required this.onTap}); +class _LogVisual { + const _LogVisual({ + required this.color, + required this.container, + required this.onContainer, + required this.icon, + }); + + final Color color; + final Color container; + final Color onContainer; + final IconData icon; +} + +_LogVisual _visualForLevel(QbitLogLevel level, ColorScheme cs) { + return switch (level) { + QbitLogLevel.critical => _LogVisual( + color: cs.error, + container: cs.errorContainer, + onContainer: cs.onErrorContainer, + icon: Icons.error_outline_rounded, + ), + QbitLogLevel.warning => _LogVisual( + color: cs.secondary, + container: cs.secondaryContainer, + onContainer: cs.onSecondaryContainer, + icon: Icons.warning_amber_rounded, + ), + QbitLogLevel.info => _LogVisual( + color: cs.tertiary, + container: cs.tertiaryContainer, + onContainer: cs.onTertiaryContainer, + icon: Icons.info_outline_rounded, + ), + QbitLogLevel.normal => _LogVisual( + color: cs.primary, + container: cs.primaryContainer, + onContainer: cs.onPrimaryContainer, + icon: Icons.article_rounded, + ), + }; +} + +class _StatePill extends StatelessWidget { + const _StatePill({required this.label, required this.visual}); + + final String label; + final _LogVisual visual; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3), + decoration: BoxDecoration( + color: visual.color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(visual.icon, size: 12, color: visual.color), + const SizedBox(width: 4), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w700, + color: visual.color, + ), + ), + ], + ), + ); + } +} + +class _LogCard extends StatelessWidget { + const _LogCard({required this.entry, required this.onTap}); final QbitLogEntry entry; final VoidCallback onTap; @@ -281,60 +352,88 @@ class _LogEntryTile extends StatelessWidget { Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); final ColorScheme cs = theme.colorScheme; + final _LogVisual v = _visualForLevel(entry.level, cs); - final Color badgeColor = switch (entry.level) { - QbitLogLevel.critical => cs.error, - QbitLogLevel.warning => cs.secondary, - QbitLogLevel.info => cs.tertiary, - QbitLogLevel.normal => cs.primary, - }; - - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 58, - child: Text( - entry.timeText, - style: theme.textTheme.labelSmall?.copyWith( - fontFamily: 'monospace', - color: cs.onSurfaceVariant, + final Widget tile = Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(Insets.md), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 44, + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: v.container, + borderRadius: BorderRadius.circular(14), ), - ), - ), - const SizedBox(width: Insets.xs), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), - decoration: BoxDecoration( - color: badgeColor.withAlpha(30), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - entry.level.label.toUpperCase(), - style: theme.textTheme.labelSmall?.copyWith( - color: badgeColor, - fontWeight: FontWeight.bold, - fontSize: 10, + child: Icon( + v.icon, + size: 22, + color: v.onContainer, ), ), - ), - const SizedBox(width: Insets.sm), - Expanded( - child: Text( - entry.message, - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', + const SizedBox(width: Insets.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entry.message, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + height: 1.35, + ), + ), + const SizedBox(height: 6), + Row( + children: [ + _StatePill( + label: entry.level.label.toUpperCase(), + visual: v, + ), + const SizedBox(width: Insets.sm), + Expanded( + child: Text( + entry.timeText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: cs.onSurfaceVariant, + fontFamily: 'monospace', + ), + ), + ), + Icon( + Icons.copy_outlined, + size: 14, + color: cs.onSurfaceVariant.withValues(alpha: 0.6), + ), + ], + ), + ], ), ), - ), - ], + ], + ), ), ), ); + + return Padding( + padding: const EdgeInsets.fromLTRB( + Insets.md, + Insets.xs, + Insets.md, + Insets.xs, + ), + child: tile, + ); } } From 9bb276c3c61a3f23bdaee4077cffd5623777d839 Mon Sep 17 00:00:00 2001 From: retransmit Date: Thu, 17 Sep 2026 22:40:39 +0530 Subject: [PATCH 5/8] fix(service_qbittorrent): fetch the log only once Logs is opened The tabs sit in an IndexedStack, which builds every tab when the screen opens, so the Logs tab fetched qBittorrent's whole log on each visit to the screen, whether or not anyone looked at it. Seen through a proxy in front of a live qBittorrent: opening the screen on the Home tab requested /api/v2/log/main. qBittorrent keeps up to 20,000 entries, and a full log came to about 2 MB of JSON. The Logs tab is now built the first time it is selected and kept after that, so its filter, search and scroll position are still there when you come back to it. --- .../lib/src/qbittorrent_home.dart | 35 ++++++++- .../test/qbit_logs_lazy_load_test.dart | 77 +++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 services/service_qbittorrent/test/qbit_logs_lazy_load_test.dart diff --git a/services/service_qbittorrent/lib/src/qbittorrent_home.dart b/services/service_qbittorrent/lib/src/qbittorrent_home.dart index 1ef84f2f..9272dc16 100644 --- a/services/service_qbittorrent/lib/src/qbittorrent_home.dart +++ b/services/service_qbittorrent/lib/src/qbittorrent_home.dart @@ -44,7 +44,10 @@ class QbittorrentHome extends ConsumerWidget { final List tabs = [ _TorrentsTab(instance: instance), QbittorrentSettingsTab(instance: instance), - QbittorrentLogsTab(instance: instance), + _BuiltOnceSelected( + selected: currentIndex == 2, + child: QbittorrentLogsTab(instance: instance), + ), ]; return Scaffold( @@ -185,6 +188,36 @@ class QbittorrentHome extends ConsumerWidget { } } +/// Builds [child] the first time its tab is selected, and keeps it after. +/// +/// [IndexedStack] builds every tab as soon as the screen opens, so a tab +/// that fetches while building fetches whether or not it is ever shown. For +/// the Logs tab that meant downloading qBittorrent's whole log, which can be +/// 20,000 entries, on every visit to the screen. +class _BuiltOnceSelected extends StatefulWidget { + const _BuiltOnceSelected({required this.selected, required this.child}); + + final bool selected; + final Widget child; + + @override + State<_BuiltOnceSelected> createState() => _BuiltOnceSelectedState(); +} + +class _BuiltOnceSelectedState extends State<_BuiltOnceSelected> { + late bool _built = widget.selected; + + @override + void didUpdateWidget(_BuiltOnceSelected oldWidget) { + super.didUpdateWidget(oldWidget); + _built = _built || widget.selected; + } + + @override + Widget build(BuildContext context) => + _built ? widget.child : const SizedBox.shrink(); +} + class _TorrentsTab extends ConsumerStatefulWidget { const _TorrentsTab({required this.instance}); diff --git a/services/service_qbittorrent/test/qbit_logs_lazy_load_test.dart b/services/service_qbittorrent/test/qbit_logs_lazy_load_test.dart new file mode 100644 index 00000000..4bb5abf6 --- /dev/null +++ b/services/service_qbittorrent/test/qbit_logs_lazy_load_test.dart @@ -0,0 +1,77 @@ +import 'package:core_models/core_models.dart'; +import 'package:core_ui/core_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:service_qbittorrent/service_qbittorrent.dart'; + +/// The log is only fetched once the Logs tab is opened. +/// +/// The tabs sit in an IndexedStack, which builds all of them when the screen +/// opens, so the Logs tab fetched qBittorrent's whole log every time the +/// screen was opened. Seen through a proxy in front of a live qBittorrent: +/// opening the screen on the Home tab requested /api/v2/log/main, and a busy +/// server keeps 20,000 entries, about 2 MB of JSON. +void main() { + testWidgets('the log is fetched when Logs is opened, not before', + (WidgetTester tester) async { + int logFetches = 0; + final ProviderContainer container = ProviderContainer( + overrides: [ + qbitRawTorrentsProvider(_instance) + .overrideWith((Ref ref) async => const []), + qbitTransferProvider(_instance) + .overrideWith((Ref ref) async => const QbitTransferInfo()), + qbitLogsProvider(_instance).overrideWith((Ref ref) async { + logFetches++; + return const []; + }), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: AtriumTheme.light(null), + home: const QbittorrentHome(instance: _instance), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + expect(logFetches, 0, reason: 'nobody has opened the Logs tab yet'); + + void selectTab(int index) => container + .read(qbitActiveTabBarIndexProvider(_instance).notifier) + .state = index; + + selectTab(2); + await tester.pump(); + await tester.pump(); + + expect(logFetches, 1); + expect(find.text('No logs available'), findsOneWidget); + + // The tab is kept once built, so going back to it does not fetch again. + selectTab(0); + await tester.pump(); + selectTab(2); + await tester.pump(); + + expect(logFetches, 1); + }); +} + +const Instance _instance = Instance( + id: 'test-qbit', + name: 'Test qBittorrent', + kind: ServiceKind.qbittorrent, + localUrl: 'http://localhost', + externalUrl: '', + urlMode: UrlMode.auto, + auth: InstanceAuth.apiKey(apiKey: 'k'), +); From 5cafdd7cb54529da09e63d2a85135e9567340405 Mon Sep 17 00:00:00 2001 From: retransmit Date: Thu, 17 Sep 2026 22:43:56 +0530 Subject: [PATCH 6/8] fix(service_qbittorrent): copy logs that fit and report a failed copy qBittorrent keeps up to 20,000 log entries. With a live server holding that many, Copy put 1.3 MB of text on the clipboard, Android refused it with a TransactionTooLargeException, and the app still said "Copied 20000 log entries to clipboard". Nothing had been copied, so pasting gave whatever was on the clipboard before. Copy now takes the newest entries that fit in 100,000 characters, keeps them in the order they happened, and says when it left older ones out. Both copy actions wait for the clipboard and say so when the platform refuses. The button's tooltip no longer promises all logs, since it copies what the filter and search leave. --- .../lib/src/qbittorrent_logs_tab.dart | 82 ++++++---- .../test/qbit_logs_copy_test.dart | 143 ++++++++++++++++++ 2 files changed, 197 insertions(+), 28 deletions(-) create mode 100644 services/service_qbittorrent/test/qbit_logs_copy_test.dart diff --git a/services/service_qbittorrent/lib/src/qbittorrent_logs_tab.dart b/services/service_qbittorrent/lib/src/qbittorrent_logs_tab.dart index bc2c6087..264e2983 100644 --- a/services/service_qbittorrent/lib/src/qbittorrent_logs_tab.dart +++ b/services/service_qbittorrent/lib/src/qbittorrent_logs_tab.dart @@ -51,37 +51,62 @@ class _QbittorrentLogsTabState extends ConsumerState { } } - void _copyAllLogs(List logs) { + /// The most text one copy puts on the clipboard. + /// + /// Android hands clipboard text to the system in a single binder call, + /// which is refused outright past about a megabyte. qBittorrent keeps up to + /// 20,000 log entries, and copying all of them came to 1.3 MB and failed. + static const int _maxCopyChars = 100000; + + Future _copyLogs(List logs) async { if (logs.isEmpty) return; - final String text = logs - .map( - (QbitLogEntry e) => - '[${e.timeText}] [${e.level.label.toUpperCase()}] ${e.message}', - ) - .join('\n'); - Clipboard.setData(ClipboardData(text: text)); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Copied ${logs.length} log ${logs.length == 1 ? "entry" : "entries"} to clipboard', - ), - duration: const Duration(seconds: 2), - ), + // The newest entries that fit, put back in the order they happened. + final List lines = []; + int chars = 0; + for (final QbitLogEntry entry in logs.reversed) { + final String line = _copyLine(entry); + if (lines.isNotEmpty && chars + line.length > _maxCopyChars) break; + lines.add(line); + chars += line.length + 1; + } + final int total = logs.length; + await _copyToClipboard( + lines.reversed.join('\n'), + lines.length == total + ? 'Copied $total log ${total == 1 ? "entry" : "entries"} to clipboard' + : 'Copied the newest ${lines.length} of $total log entries to ' + 'clipboard', + const Duration(seconds: 2), ); } - void _copyLogEntry(QbitLogEntry entry) { - Clipboard.setData( - ClipboardData( - text: - '[${entry.timeText}] [${entry.level.label.toUpperCase()}] ${entry.message}', - ), - ); + Future _copyLogEntry(QbitLogEntry entry) => _copyToClipboard( + _copyLine(entry), + 'Log entry copied to clipboard', + const Duration(seconds: 1), + ); + + String _copyLine(QbitLogEntry entry) => + '[${entry.timeText}] [${entry.level.label.toUpperCase()}] ${entry.message}'; + + /// Copies [text], then says whether it worked. + /// + /// The platform can refuse the write, and a success message shown anyway + /// leaves someone pasting whatever was on the clipboard before. + Future _copyToClipboard( + String text, + String copiedMessage, + Duration duration, + ) async { + String message = copiedMessage; + try { + await Clipboard.setData(ClipboardData(text: text)); + } on PlatformException { + message = 'Could not copy to the clipboard'; + } + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Log entry copied to clipboard'), - duration: Duration(seconds: 1), - ), + SnackBar(content: Text(message), duration: duration), ); } @@ -139,12 +164,13 @@ class _QbittorrentLogsTabState extends ConsumerState { ), IconButton( icon: const Icon(Icons.copy_all_outlined), - tooltip: 'Copy all logs', + // It copies what the filter and search leave, not everything. + tooltip: 'Copy logs', onPressed: () { final List? currentLogs = logsAsync.value; if (currentLogs != null) { final List filtered = _filterLogs(currentLogs); - _copyAllLogs(filtered); + _copyLogs(filtered); } }, ), diff --git a/services/service_qbittorrent/test/qbit_logs_copy_test.dart b/services/service_qbittorrent/test/qbit_logs_copy_test.dart new file mode 100644 index 00000000..2ebadc75 --- /dev/null +++ b/services/service_qbittorrent/test/qbit_logs_copy_test.dart @@ -0,0 +1,143 @@ +import 'package:core_models/core_models.dart'; +import 'package:core_ui/core_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:service_qbittorrent/service_qbittorrent.dart'; + +/// Copying logs has to fit on the clipboard and say truthfully what it did. +/// +/// qBittorrent keeps up to 20,000 log entries. With a live server holding +/// that many, Copy put 1.3 MB on the clipboard, Android refused it with a +/// TransactionTooLargeException, and the app still said "Copied 20000 log +/// entries to clipboard" while nothing had been copied. +void main() { + const Instance instance = Instance( + id: 'qbit-test', + name: 'My qBittorrent', + kind: ServiceKind.qbittorrent, + localUrl: 'http://localhost:8080', + externalUrl: '', + urlMode: UrlMode.auto, + auth: InstanceAuth.apiKey(apiKey: 'k'), + ); + + // Entries shaped like the ones a busy server fills its log with, each + // numbered so the order of copied lines can be checked. + List logsOf(int count) => [ + for (int i = 0; i < count; i++) + QbitLogEntry( + id: i, + message: 'WebAPI login success. IP: ::ffff:172.20.0.1 #$i', + timestamp: 1789662480 + i, + type: 1, + ), + ]; + + Future pumpLogs(WidgetTester tester, int count) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + qbitLogsProvider(instance) + .overrideWith((Ref ref) async => logsOf(count)), + ], + child: MaterialApp( + theme: AtriumTheme.light(null), + home: const QbittorrentLogsTab(instance: instance), + ), + ), + ); + await tester.pumpAndSettle(); + } + + // Stands in for the platform clipboard, recording what was copied, or + // refusing the way Android does when the text is too large. + void clipboard(WidgetTester tester, {void Function(String)? onCopy}) { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (MethodCall call) async { + if (call.method != 'Clipboard.setData') return null; + if (onCopy == null) { + throw PlatformException( + code: 'error', + message: 'android.os.TransactionTooLargeException', + ); + } + onCopy((call.arguments as Map)['text']! as String); + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null), + ); + } + + Future copy(WidgetTester tester) async { + await tester.tap(find.byTooltip('Copy logs')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + } + + testWidgets('a log that fits is copied whole, oldest first', + (WidgetTester tester) async { + String? copied; + clipboard(tester, onCopy: (String text) => copied = text); + await pumpLogs(tester, 3); + + await copy(tester); + + expect(copied!.split('\n'), hasLength(3)); + expect(copied!.split('\n').first, endsWith('#0')); + expect(copied!.split('\n').last, endsWith('#2')); + expect(find.text('Copied 3 log entries to clipboard'), findsOneWidget); + }); + + testWidgets('a full log copies the newest entries that fit, and says so', + (WidgetTester tester) async { + String? copied; + clipboard(tester, onCopy: (String text) => copied = text); + await pumpLogs(tester, 20000); + + await copy(tester); + + expect(copied!.length, lessThanOrEqualTo(100000)); + final List lines = copied!.split('\n'); + expect(lines.length, lessThan(20000)); + // The newest entry is kept, and what came before it runs on unbroken. + expect(lines.last, endsWith('#19999')); + expect(lines.first, endsWith('#${20000 - lines.length}')); + expect( + find.text( + 'Copied the newest ${lines.length} of 20000 log entries to clipboard', + ), + findsOneWidget, + ); + }); + + testWidgets('a refused copy says so instead of claiming it worked', + (WidgetTester tester) async { + clipboard(tester); + await pumpLogs(tester, 3); + + await copy(tester); + + expect(find.textContaining('Copied'), findsNothing); + expect(find.text('Could not copy to the clipboard'), findsOneWidget); + }); + + testWidgets('tapping one entry reports a refused copy too', + (WidgetTester tester) async { + clipboard(tester); + await pumpLogs(tester, 3); + + await tester.tap(find.textContaining('#2')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('Log entry copied to clipboard'), findsNothing); + expect(find.text('Could not copy to the clipboard'), findsOneWidget); + }); +} From a0f0326b57e7f290e485545f57ae9e2a09bd6bd7 Mon Sep 17 00:00:00 2001 From: retransmit Date: Thu, 17 Sep 2026 22:51:44 +0530 Subject: [PATCH 7/8] docs(service_qbittorrent): say log timestamps became seconds in 4.5.0 The timestamp comment called milliseconds the format and seconds the one older versions send. It is the other way round: qBittorrent's API documentation says the log switched from milliseconds to seconds in 4.5.0, and a live 5.2.3 sends seconds. The code already reads both, but a reader trusting the comment could drop the branch that every current server needs. --- .../service_qbittorrent/lib/src/models/qbit_log_entry.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/service_qbittorrent/lib/src/models/qbit_log_entry.dart b/services/service_qbittorrent/lib/src/models/qbit_log_entry.dart index 4b3f0f78..09aad584 100644 --- a/services/service_qbittorrent/lib/src/models/qbit_log_entry.dart +++ b/services/service_qbittorrent/lib/src/models/qbit_log_entry.dart @@ -34,7 +34,8 @@ class QbitLogEntry { /// The content of the log entry. final String message; - /// Timestamp (epoch milliseconds, or seconds on some older versions). + /// When the message was logged, in seconds since the epoch. qBittorrent + /// sent milliseconds before 4.5.0, so [dateTime] reads either. final int timestamp; /// Message type: 1 = normal, 2 = info, 4 = warning, 8 = critical. From c1f65ae59412fc42d8d2ed47b89a61264d6406f5 Mon Sep 17 00:00:00 2001 From: retransmit Date: Thu, 17 Sep 2026 23:34:19 +0530 Subject: [PATCH 8/8] fix(service_qbittorrent): ask for network interfaces by their real routes The Advanced settings pickers for the network interface and the address to bind to never listed anything. The client asked for app/networkInterfacesList and app/networkInterfaceAddressesList, which qBittorrent does not have: on a live 5.2.3 both answer 404, and the routes are networkInterfaceList and networkInterfaceAddressList. The client caught the failure and returned an empty list, so the pickers only offered their built-in choices and nothing looked wrong. The address route also needs its iface parameter even when it is empty. Without it qBittorrent answers 400, and an empty value is how every address is asked for, which is what the default "Any interface" needs. It is now always sent. --- .../lib/src/qbittorrent_client.dart | 10 +- .../test/qbit_network_interfaces_test.dart | 109 ++++++++++++++++++ 2 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 services/service_qbittorrent/test/qbit_network_interfaces_test.dart diff --git a/services/service_qbittorrent/lib/src/qbittorrent_client.dart b/services/service_qbittorrent/lib/src/qbittorrent_client.dart index 2de182a0..79c87211 100644 --- a/services/service_qbittorrent/lib/src/qbittorrent_client.dart +++ b/services/service_qbittorrent/lib/src/qbittorrent_client.dart @@ -597,7 +597,7 @@ class QbittorrentClient { _guarded(() async { try { final Response res = - await _dio.get('api/v2/app/networkInterfacesList'); + await _dio.get('api/v2/app/networkInterfaceList'); dynamic raw = res.data; if (raw is String && raw.isNotEmpty) { try { @@ -629,10 +629,10 @@ class QbittorrentClient { _guarded(() async { try { final Response res = await _dio.get( - 'api/v2/app/networkInterfaceAddressesList', - queryParameters: iface != null && iface.isNotEmpty - ? {'iface': iface} - : null, + 'api/v2/app/networkInterfaceAddressList', + // Sent even when empty: qBittorrent answers 400 without it, and + // an empty value is how every address is asked for. + queryParameters: {'iface': iface ?? ''}, ); dynamic raw = res.data; if (raw is String && raw.isNotEmpty) { diff --git a/services/service_qbittorrent/test/qbit_network_interfaces_test.dart b/services/service_qbittorrent/test/qbit_network_interfaces_test.dart new file mode 100644 index 00000000..9bbccce2 --- /dev/null +++ b/services/service_qbittorrent/test/qbit_network_interfaces_test.dart @@ -0,0 +1,109 @@ +import 'dart:typed_data'; + +import 'package:cookie_jar/cookie_jar.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:service_qbittorrent/service_qbittorrent.dart'; + +/// Answers the two network interface routes the way qBittorrent 5.2.3 does. +/// +/// The bodies and status codes below were taken from a live server: any +/// route it does not have is a 404, and the address route is a 400 unless +/// `iface` is sent, where an empty value lists every address. +class _Qbittorrent implements HttpClientAdapter { + final List requested = []; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + requested.add(options.uri); + final Map query = options.uri.queryParameters; + return switch (options.uri.path) { + '/api/v2/app/networkInterfaceList' => _json( + '[{"name":"lo","value":"lo"},{"name":"eth0","value":"eth0"}]', + ), + '/api/v2/app/networkInterfaceAddressList' => switch (query['iface']) { + null => _text(400, 'Missing required parameters: iface'), + '' => _json('["127.0.0.1","::1","172.20.0.2"]'), + 'eth0' => _json('["172.20.0.2"]'), + _ => _json('[]'), + }, + _ => _text(404, 'Endpoint does not exist'), + }; + } + + ResponseBody _json(String body) => ResponseBody.fromString( + body, + 200, + headers: >{ + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + + ResponseBody _text(int status, String body) => ResponseBody.fromString( + body, + status, + headers: >{ + Headers.contentTypeHeader: ['text/plain; charset=UTF-8'], + }, + ); + + @override + void close({bool force = false}) {} +} + +/// The Advanced settings pickers list the host's interfaces and addresses. +/// +/// They never listed anything: the client asked for networkInterfacesList +/// and networkInterfaceAddressesList, routes qBittorrent does not have, and +/// returned an empty list when both came back 404. +void main() { + late _Qbittorrent qbittorrent; + late QbittorrentClient client; + + setUp(() { + qbittorrent = _Qbittorrent(); + client = QbittorrentClient( + dio: Dio(BaseOptions(baseUrl: 'https://qbit.example.test/')) + ..httpClientAdapter = qbittorrent, + cookies: CookieJar(), + username: '', + password: '', + apiKey: 'k', + ); + }); + + test('lists the network interfaces', () async { + expect( + await client.getNetworkInterfaces(), + >[ + {'name': 'lo', 'value': 'lo'}, + {'name': 'eth0', 'value': 'eth0'}, + ], + ); + }); + + test('lists every address when no interface is chosen', () async { + // "Any interface" is the default, and qBittorrent needs iface sent + // empty to answer it rather than refusing the request. + expect( + await client.getNetworkInterfaceAddresses(), + ['127.0.0.1', '::1', '172.20.0.2'], + ); + expect(await client.getNetworkInterfaceAddresses(iface: ''), hasLength(3)); + expect( + qbittorrent.requested.map((Uri uri) => uri.queryParameters['iface']), + everyElement(''), + ); + }); + + test('lists the addresses of the chosen interface', () async { + expect( + await client.getNetworkInterfaceAddresses(iface: 'eth0'), + ['172.20.0.2'], + ); + }); +}