From 1c86be52340b8e57d9f18e711caf1949cfdb43ee Mon Sep 17 00:00:00 2001 From: chanderlud Date: Fri, 7 Aug 2026 01:48:48 +0000 Subject: [PATCH 1/5] fix(ui): remove Web audio selector gap Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../settings/sections/audio_settings.dart | 2 +- .../sections/audio_settings_test.dart | 28 ++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/lib/screens/settings/sections/audio_settings.dart b/lib/screens/settings/sections/audio_settings.dart index 98ead8d2..f723be18 100644 --- a/lib/screens/settings/sections/audio_settings.dart +++ b/lib/screens/settings/sections/audio_settings.dart @@ -184,7 +184,7 @@ class _AudioSettingsState extends State { return const SizedBox.shrink(); }, ), - const SizedBox(height: 20), + if (!kIsWeb) const SizedBox(height: 20), Row(children: [ Selector( selector: (context, controller) => diff --git a/test/screens/settings/sections/audio_settings_test.dart b/test/screens/settings/sections/audio_settings_test.dart index a91727e4..5de0628e 100644 --- a/test/screens/settings/sections/audio_settings_test.dart +++ b/test/screens/settings/sections/audio_settings_test.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; @@ -170,6 +170,32 @@ void main() { ); expect(settings.inputDeviceId, 'input-1'); }); + + testWidgets( + 'Web hides audio device selectors and their following spacer', + (WidgetTester tester) async { + final audioDevices = _FakeAudioDevices(telepathy: _FakeTelepathy()); + await _pumpAudioSettings( + tester, + audioDevices, + _FakeAudioSettingsController(), + ); + + expect(find.text('Input Device'), findsNothing); + expect(find.text('Output Device'), findsNothing); + + final audioOptionsBottom = + tester.getBottomLeft(find.text('Audio Options')).dy; + final soundTestTop = + tester.getTopLeft(find.widgetWithText(Button, 'Sound Test')).dy; + expect( + soundTestTop - audioOptionsBottom, + lessThan(30), + reason: 'Web must not retain the hidden selector spacer', + ); + }, + skip: !kIsWeb, + ); } Future _pumpAudioSettings( From 6d761ae2cfca67f51393dceb6d943c3e6affe292 Mon Sep 17 00:00:00 2001 From: chanderlud Date: Fri, 7 Aug 2026 01:48:53 +0000 Subject: [PATCH 2/5] fix(ui): hide unsupported Web network controls Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- lib/screens/settings/sections/networking.dart | 56 ++++++++++--------- .../settings/sections/networking_test.dart | 25 ++++++++- 2 files changed, 52 insertions(+), 29 deletions(-) diff --git a/lib/screens/settings/sections/networking.dart b/lib/screens/settings/sections/networking.dart index c432d076..6b9141cd 100644 --- a/lib/screens/settings/sections/networking.dart +++ b/lib/screens/settings/sections/networking.dart @@ -212,34 +212,36 @@ class NetworkSettingsState extends State { ], ), ), - Center( - child: Wrap( - spacing: 20, - runSpacing: 20, - children: [ - SizedBox( - width: width, - child: TextInput( - labelText: 'Bind Addresses', - hintText: '0.0.0.0, ::, 127.0.0.1', - controller: _bindAddressesInput, - enabled: !isRestartSafe, - onChanged: (_) => _updateUnsavedChanges(), - errorText: _bindAddressesError, - )), - SizedBox( - width: width, - child: TextInput( - labelText: 'Listen Port', - controller: _listenPortInput, - enabled: !isRestartSafe, - onChanged: (_) => _updateUnsavedChanges(), - errorText: _listenPortError, - )), - ], + if (!kIsWeb) ...[ + Center( + child: Wrap( + spacing: 20, + runSpacing: 20, + children: [ + SizedBox( + width: width, + child: TextInput( + labelText: 'Bind Addresses', + hintText: '0.0.0.0, ::, 127.0.0.1', + controller: _bindAddressesInput, + enabled: !isRestartSafe, + onChanged: (_) => _updateUnsavedChanges(), + errorText: _bindAddressesError, + )), + SizedBox( + width: width, + child: TextInput( + labelText: 'Listen Port', + controller: _listenPortInput, + enabled: !isRestartSafe, + onChanged: (_) => _updateUnsavedChanges(), + errorText: _listenPortError, + )), + ], + ), ), - ), - const SizedBox(height: 8), + const SizedBox(height: 8), + ], _buildRelaysSection(isRestartSafe), const SizedBox(height: 8), if (!kIsWeb) _buildDnsSection(width, isRestartSafe), diff --git a/test/screens/settings/sections/networking_test.dart b/test/screens/settings/sections/networking_test.dart index 01bc7f47..843c948c 100644 --- a/test/screens/settings/sections/networking_test.dart +++ b/test/screens/settings/sections/networking_test.dart @@ -1,5 +1,4 @@ -import 'dart:typed_data'; - +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; @@ -329,6 +328,28 @@ void main() { 'Save button must be re-enabled after a backend error so the user can retry', ); }); + + testWidgets( + 'Web hides unsupported network fields and retains supported controls', + (WidgetTester tester) async { + final recorder = _NetworkConfigRecorder( + listenPort: 40142, + bindAddresses: const ['0.0.0.0', '::'], + ); + await tester.pumpNetworkSettings( + controller: _FakeNetworkSettingsController(recorder), + stateController: StateController(), + telepathy: _FakeTelepathy(), + ); + + expect(find.text('Bind Addresses'), findsNothing); + expect(find.text('Listen Port'), findsNothing); + expect(find.text('Use Custom DNS'), findsNothing); + expect(find.text('Use Custom Relays'), findsOneWidget); + expect(find.text('Use Custom Pkarr Relay'), findsOneWidget); + }, + skip: !kIsWeb, + ); } extension on WidgetTester { From 09d38666af74ef03eb033ace61d169bcd1eaa597 Mon Sep 17 00:00:00 2001 From: chanderlud Date: Fri, 7 Aug 2026 01:48:58 +0000 Subject: [PATCH 3/5] fix(ui): restore title after leaving settings Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- lib/screens/home/home_page.dart | 8 ++++ test/widgets/home/home_page_layout_test.dart | 39 ++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/lib/screens/home/home_page.dart b/lib/screens/home/home_page.dart index 5444aa69..0f5f8a8d 100644 --- a/lib/screens/home/home_page.dart +++ b/lib/screens/home/home_page.dart @@ -14,6 +14,14 @@ class HomePage extends StatelessWidget { @override Widget build(BuildContext context) { + return Title( + title: 'Telepathy', + color: const Color(0xFF000000), + child: _buildPage(context), + ); + } + + Widget _buildPage(BuildContext context) { return Scaffold( body: Padding( padding: const EdgeInsets.all(20.0), diff --git a/test/widgets/home/home_page_layout_test.dart b/test/widgets/home/home_page_layout_test.dart index 2afd39e1..1b68e1e4 100644 --- a/test/widgets/home/home_page_layout_test.dart +++ b/test/widgets/home/home_page_layout_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:telepathy/screens/home/home_page.dart'; import 'package:telepathy/widgets/contacts/contacts_list.dart'; import 'package:telepathy/widgets/home/home_tab_view.dart'; @@ -64,4 +65,42 @@ void main() { expect(tester.getRect(find.byType(ContactsList)).height, 250, reason: 'non-compact heights in the narrow branch use the 250px cap'); }); + + testWidgets('popping Settings restores the Telepathy title', + (WidgetTester tester) async { + final harness = await Harness.create(); + setCanvasSize(tester, const Size(641, 900)); + await pumpApp(tester, harness); + + final homeContext = tester.element(find.byType(HomePage)); + final settingsRoute = Navigator.of(homeContext).push( + MaterialPageRoute( + builder: (BuildContext context) => Title( + title: 'Telepathy - Settings', + color: const Color(0xFF000000), + child: const SizedBox.expand(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byWidgetPredicate( + (Widget widget) => + widget is Title && widget.title == 'Telepathy - Settings', + ), + findsOneWidget, + ); + + Navigator.of(homeContext).pop(); + await tester.pumpAndSettle(); + await settingsRoute; + + expect( + find.byWidgetPredicate( + (Widget widget) => widget is Title && widget.title == 'Telepathy', + ), + findsOneWidget, + ); + }); } From facd4a3fa6295f4b853c968878ff226f476e5ec2 Mon Sep 17 00:00:00 2001 From: chanderlud Date: Fri, 7 Aug 2026 01:49:03 +0000 Subject: [PATCH 4/5] fix(ui): dismiss narrow settings menu Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- lib/screens/settings/view.dart | 123 ++++++++----- test/screens/settings/view_test.dart | 265 +++++++++++++++++++++++++++ 2 files changed, 347 insertions(+), 41 deletions(-) create mode 100644 test/screens/settings/view_test.dart diff --git a/lib/screens/settings/view.dart b/lib/screens/settings/view.dart index 28d158b2..c2201a7d 100644 --- a/lib/screens/settings/view.dart +++ b/lib/screens/settings/view.dart @@ -41,6 +41,8 @@ class SettingsPageState extends State late AnimationController _animationController; late Animation _menuSlideAnimation; + final FocusNode _menuFocusNode = FocusNode(); + final Object _menuTapRegionGroup = Object(); @override void initState() { @@ -66,6 +68,7 @@ class SettingsPageState extends State @override void dispose() { + _menuFocusNode.dispose(); _animationController.dispose(); super.dispose(); } @@ -78,6 +81,32 @@ class SettingsPageState extends State } } + void _setMenuVisibility(bool visible) { + if (showMenu == visible) return; + + setState(() { + if (visible) { + _animationController.reverse(); + } else { + _animationController.forward(); + } + showMenu = visible; + }); + + if (visible) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && showMenu == true && widget.constraints.maxWidth < 600) { + _menuFocusNode.requestFocus(); + } + }); + } + } + + void _dismissNarrowMenu() { + if (widget.constraints.maxWidth >= 600 || showMenu != true) return; + _setMenuVisibility(false); + } + @override Widget build(BuildContext context) { BoxConstraints constraints = widget.constraints; @@ -145,50 +174,62 @@ class SettingsPageState extends State ), )), ), - if (constraints.maxWidth > 600 || (showMenu ?? true)) - SlideTransition( - position: _menuSlideAnimation, - child: Container( - width: 200, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceDim, - borderRadius: const BorderRadius.only( - topRight: Radius.circular(8), - bottomRight: Radius.circular(8), + Positioned.fill( + child: Focus( + focusNode: _menuFocusNode, + onFocusChange: (hasFocus) { + if (!hasFocus) _dismissNarrowMenu(); + }, + child: Stack( + children: [ + if (constraints.maxWidth > 600 || (showMenu ?? true)) + TapRegion( + groupId: _menuTapRegionGroup, + onTapOutside: (_) => _dismissNarrowMenu(), + child: SlideTransition( + position: _menuSlideAnimation, + child: Container( + width: 200, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceDim, + borderRadius: const BorderRadius.only( + topRight: Radius.circular(8), + bottomRight: Radius.circular(8), + ), + ), + padding: const EdgeInsets.only(top: 60), + child: SettingsMenu( + selected: _section, + onSectionSelected: (section) => + tapHandler(section), + showOverlayItem: !kIsWeb && Platform.isWindows, + ), + ), + ), + ), + TapRegion( + groupId: _menuTapRegionGroup, + child: SettingsHeader( + isNarrow: constraints.maxWidth < 600, + showMenu: showMenu ?? true, + onBack: () async { + if (_section == SettingsSection.networking && + (_key.currentState?.unsavedChanges ?? false)) { + bool leave = await unsavedConfirmation(context); + if (!leave) return; + } + + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + onToggleMenu: () => + _setMenuVisibility(!(showMenu ?? true)), + ), ), - ), - padding: const EdgeInsets.only(top: 60), - child: SettingsMenu( - selected: _section, - onSectionSelected: (section) => tapHandler(section), - showOverlayItem: !kIsWeb && Platform.isWindows, - ), + ], ), ), - SettingsHeader( - isNarrow: constraints.maxWidth < 600, - showMenu: showMenu ?? true, - onBack: () async { - if (_section == SettingsSection.networking && - (_key.currentState?.unsavedChanges ?? false)) { - bool leave = await unsavedConfirmation(context); - if (!leave) return; - } - - if (context.mounted) { - Navigator.of(context).pop(); - } - }, - onToggleMenu: () { - setState(() { - if (showMenu ?? true) { - _animationController.forward(); - } else { - _animationController.reverse(); - } - showMenu = !(showMenu ?? true); - }); - }, ), ], )); diff --git a/test/screens/settings/view_test.dart b/test/screens/settings/view_test.dart new file mode 100644 index 00000000..09980c6a --- /dev/null +++ b/test/screens/settings/view_test.dart @@ -0,0 +1,265 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; +import 'package:telepathy/controllers/audio_devices_controller.dart'; +import 'package:telepathy/controllers/audio_settings_controller.dart'; +import 'package:telepathy/controllers/network_settings_controller.dart'; +import 'package:telepathy/controllers/preferences_controller.dart'; +import 'package:telepathy/controllers/state_controller.dart'; +import 'package:telepathy/controllers/statistics_controller.dart'; +import 'package:telepathy/core/rust/flutter.dart'; +import 'package:telepathy/core/rust/lib.dart'; +import 'package:telepathy/core/rust/player.dart'; +import 'package:telepathy/core/rust/types.dart'; +import 'package:telepathy/screens/settings/header.dart'; +import 'package:telepathy/screens/settings/menu.dart'; +import 'package:telepathy/screens/settings/view.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + tearDown(() { + SharedPreferencesAsyncPlatform.instance = null; + }); + + testWidgets('narrow menu dismisses on outside interaction', + (WidgetTester tester) async { + await _pumpSettingsPage(tester, maxWidth: 500); + + await _openMenu(tester); + expect(find.byType(SettingsMenu), findsOneWidget); + + await tester.tapAt(const Offset(850, 800)); + await tester.pump(); + + expect(find.byType(SettingsMenu), findsNothing); + }); + + testWidgets('narrow menu dismisses when focus leaves it', + (WidgetTester tester) async { + final outsideFocusNode = FocusNode(); + addTearDown(outsideFocusNode.dispose); + await _pumpSettingsPage( + tester, + maxWidth: 500, + outsideFocusNode: outsideFocusNode, + ); + + await _openMenu(tester); + expect(find.byType(SettingsMenu), findsOneWidget); + + outsideFocusNode.requestFocus(); + await tester.pumpAndSettle(); + + expect(outsideFocusNode.hasFocus, isTrue); + expect(find.byType(SettingsMenu), findsNothing); + }); + + testWidgets('narrow menu keeps header and internal menu interactions', + (WidgetTester tester) async { + await _pumpSettingsPage(tester, maxWidth: 500); + + await _openMenu(tester); + await tester.tap(_menuButton); + await tester.pump(); + expect(find.byType(SettingsMenu), findsNothing); + + await _openMenu(tester); + await tester.tap(find.text('Audio & Video')); + await tester.pump(); + + expect(find.byType(SettingsMenu), findsOneWidget); + }); + + testWidgets('wide menu stays visible after outside interaction', + (WidgetTester tester) async { + await _pumpSettingsPage(tester, maxWidth: 700); + expect(find.byType(SettingsMenu), findsOneWidget); + + await tester.tapAt(const Offset(850, 800)); + await tester.pump(); + + expect(find.byType(SettingsMenu), findsOneWidget); + }); +} + +final Finder _menuButton = find + .descendant( + of: find.byType(SettingsHeader), + matching: find.byType(IconButton), + ) + .last; + +Future _openMenu(WidgetTester tester) async { + await tester.tap(_menuButton); + await tester.pumpAndSettle(); +} + +Future _pumpSettingsPage( + WidgetTester tester, { + required double maxWidth, + FocusNode? outsideFocusNode, +}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(900, 900); + addTearDown(tester.view.reset); + + final options = SharedPreferencesAsync(); + final audioSettingsController = AudioSettingsController(options: options); + await audioSettingsController.init(); + final preferencesController = PreferencesController(options: options); + await preferencesController.init(); + final telepathy = _FakeTelepathy(); + + final page = Scaffold( + body: SettingsPage( + constraints: BoxConstraints(maxWidth: maxWidth, maxHeight: 900), + ), + ); + final home = outsideFocusNode == null + ? page + : Row( + children: [ + Expanded(child: page), + Focus( + focusNode: outsideFocusNode, + child: const SizedBox(width: 1, height: 1), + ), + ], + ); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: StateController()), + ChangeNotifierProvider.value( + value: audioSettingsController, + ), + ChangeNotifierProvider.value( + value: preferencesController, + ), + ChangeNotifierProvider.value( + value: _FakeNetworkSettingsController(options: options), + ), + ChangeNotifierProvider.value( + value: _FakeAudioDevices(telepathy: telepathy), + ), + ChangeNotifierProvider.value( + value: StatisticsController(), + ), + Provider.value(value: telepathy), + Provider.value(value: _FakeSoundPlayer()), + ], + child: MaterialApp(home: home), + ), + ); + await tester.pump(); +} + +class _FakeAudioDevices extends AudioDevices { + _FakeAudioDevices({required super.telepathy}); + + @override + List get inputDevices => const [ + AudioDevice(name: 'Default', id: ''), + ]; + + @override + List get outputDevices => const [ + AudioDevice(name: 'Default', id: ''), + ]; + + @override + bool get hasLoadedDevices => false; + + @override + void pauseUpdates() {} + + @override + void startUpdates() {} +} + +class _FakeNetworkSettingsController extends NetworkSettingsController { + _FakeNetworkSettingsController({required super.options}) { + codecConfig = _FakeCodecConfig(); + screenshareConfig = _FakeScreenshareConfig(); + } +} + +class _FakeCodecConfig implements CodecConfig { + @override + void dispose() {} + + @override + bool get isDisposed => false; + + @override + void setEnabled({required bool enabled}) {} + + @override + void setResidualBits({required double residualBits}) {} + + @override + void setVbr({required bool vbr}) {} + + @override + (bool, bool, double) toValues() => (true, true, 5); +} + +class _FakeScreenshareConfig implements ScreenshareConfig { + @override + Future capabilities() async => _FakeCapabilities(); + + @override + void dispose() {} + + @override + bool get isDisposed => false; + + @override + Future recordingConfig() async => null; + + @override + Uint8List toBytes() => Uint8List(0); + + @override + Future updateRecordingConfig({ + required String encoder, + required String device, + required int bitrate, + required int framerate, + int? height, + }) async {} +} + +class _FakeCapabilities implements Capabilities { + @override + List devices() => const []; + + @override + void dispose() {} + + @override + List encoders() => const []; + + @override + bool get isDisposed => false; +} + +class _FakeSoundPlayer implements SoundPlayer { + @override + Object? noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeTelepathy implements Telepathy { + @override + Object? noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} From a4eda7860963aed8bd6e823a4b20f54a5afc7212 Mon Sep 17 00:00:00 2001 From: chanderlud Date: Fri, 7 Aug 2026 01:49:11 +0000 Subject: [PATCH 5/5] fix(web): add branded startup loader Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- test/web/loading_screen_test.dart | 29 +++++++++++++ web/index.html | 67 +++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 test/web/loading_screen_test.dart diff --git a/test/web/loading_screen_test.dart b/test/web/loading_screen_test.dart new file mode 100644 index 00000000..e7b02b60 --- /dev/null +++ b/test/web/loading_screen_test.dart @@ -0,0 +1,29 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('web loading screen remains branded until Flutter first frame', () { + final indexHtml = File('web/index.html').readAsStringSync(); + const firstFrameListener = "window.addEventListener('flutter-first-frame'"; + const bootstrapScript = + ''; + + expect(indexHtml, contains('Telepathy')); + expect(indexHtml, contains(bootstrapScript)); + expect(indexHtml, contains('id="telepathy-loader"')); + expect(indexHtml, contains('role="status"')); + expect(indexHtml, contains('aria-live="polite"')); + expect(indexHtml, contains('icons/Icon-512.png')); + expect(indexHtml, contains('#222425')); + expect(indexHtml, contains('#5538e5')); + expect(indexHtml, contains('transition: opacity')); + expect(indexHtml, contains("loader.classList.add('is-hidden')")); + expect(indexHtml, contains('loader.remove()')); + + final firstFrameListenerIndex = indexHtml.indexOf(firstFrameListener); + final bootstrapScriptIndex = indexHtml.indexOf(bootstrapScript); + expect(firstFrameListenerIndex, greaterThanOrEqualTo(0)); + expect(bootstrapScriptIndex, greaterThan(firstFrameListenerIndex)); + }); +} diff --git a/web/index.html b/web/index.html index 8c8cdfcc..2f844dcb 100644 --- a/web/index.html +++ b/web/index.html @@ -16,8 +16,75 @@ Telepathy + +
+ + +
+