From 8939911ce17e0c8e77097990c6c24a9cd0a618f1 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Sat, 22 Nov 2025 23:15:12 +0100 Subject: [PATCH] app: add instance selector to settings (#497) * app: add instance selector to settings * app: add instance selector to settings --- app/lib/main.dart | 356 +++++++++++++++++++++++++++-- app/pubspec.lock | 12 +- app/pubspec.yaml | 4 +- app/test/mesh_message_test.dart | 47 ++++ app/test/messages_screen_test.dart | 80 ++++++- app/test/settings_screen_test.dart | 67 ++++++ app/test/widget_test.dart | 6 +- 7 files changed, 534 insertions(+), 38 deletions(-) create mode 100644 app/test/settings_screen_test.dart diff --git a/app/lib/main.dart b/app/lib/main.dart index dabcabf..056fb95 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -21,15 +21,59 @@ void main() { runApp(const PotatoMeshReaderApp()); } +/// Function type used to fetch messages from a specific endpoint. +typedef MessageFetcher = Future> Function({ + http.Client? client, + String domain, +}); + /// Meshtastic Reader root widget that configures theming and the home screen. -class PotatoMeshReaderApp extends StatelessWidget { +class PotatoMeshReaderApp extends StatefulWidget { const PotatoMeshReaderApp({ super.key, this.fetcher = fetchMessages, + this.instanceFetcher = fetchInstances, + this.initialDomain = 'potatomesh.net', }); /// Fetch function injected to simplify testing and offline previews. - final Future> Function() fetcher; + final MessageFetcher fetcher; + + /// Loader for federation instance metadata, overridable in tests. + final Future> Function({http.Client? client}) + instanceFetcher; + + /// Initial endpoint domain used when the app boots. + final String initialDomain; + + @override + State createState() => _PotatoMeshReaderAppState(); +} + +class _PotatoMeshReaderAppState extends State { + late String _endpointDomain; + int _endpointVersion = 0; + + @override + void initState() { + super.initState(); + _endpointDomain = widget.initialDomain; + } + + void _handleEndpointChanged(String newDomain) { + if (newDomain.isEmpty || newDomain == _endpointDomain) { + return; + } + + setState(() { + _endpointDomain = newDomain; + _endpointVersion += 1; + }); + } + + Future> _fetchMessagesForCurrentDomain() { + return widget.fetcher(domain: _endpointDomain); + } @override Widget build(BuildContext context) { @@ -51,7 +95,22 @@ class PotatoMeshReaderApp extends StatelessWidget { ), ), ), - home: MessagesScreen(fetcher: fetcher), + home: MessagesScreen( + key: ValueKey(_endpointDomain), + fetcher: _fetchMessagesForCurrentDomain, + resetToken: _endpointVersion, + onOpenSettings: (context) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SettingsScreen( + currentDomain: _endpointDomain, + onDomainChanged: _handleEndpointChanged, + loadInstances: () => widget.instanceFetcher(), + ), + ), + ); + }, + ), ); } } @@ -61,11 +120,19 @@ class MessagesScreen extends StatefulWidget { const MessagesScreen({ super.key, this.fetcher = fetchMessages, + this.onOpenSettings, + this.resetToken = 0, }); /// Fetch function used to load messages from the PotatoMesh API. final Future> Function() fetcher; + /// Handler invoked when the settings icon is tapped. + final void Function(BuildContext context)? onOpenSettings; + + /// Bumps when the endpoint changes to force a refresh of cached data. + final int resetToken; + @override State createState() => _MessagesScreenState(); } @@ -84,7 +151,8 @@ class _MessagesScreenState extends State { @override void didUpdateWidget(covariant MessagesScreen oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.fetcher != widget.fetcher) { + if (oldWidget.fetcher != widget.fetcher || + oldWidget.resetToken != widget.resetToken) { setState(() { _future = widget.fetcher(); }); @@ -122,11 +190,9 @@ class _MessagesScreenState extends State { tooltip: 'Settings', icon: const Icon(Icons.settings), onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const SettingsScreen(), - ), - ); + if (widget.onOpenSettings != null) { + widget.onOpenSettings!(context); + } }, ), ], @@ -234,27 +300,180 @@ class ChatLine extends StatelessWidget { } } -/// MVP settings placeholder offering endpoint and about info. -class SettingsScreen extends StatelessWidget { - const SettingsScreen({super.key}); +/// MVP settings view offering endpoint selection and about info. +class SettingsScreen extends StatefulWidget { + const SettingsScreen({ + super.key, + required this.currentDomain, + required this.onDomainChanged, + this.loadInstances = fetchInstances, + }); + + /// Currently selected endpoint domain. + final String currentDomain; + + /// Callback fired when the user changes the endpoint. + final ValueChanged onDomainChanged; + + /// Loader used to fetch federation instance metadata. + final Future> Function() loadInstances; + + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + static const String _defaultDomain = 'potatomesh.net'; + static const String _defaultName = 'BerlinMesh'; + List _instances = const []; + bool _loading = false; + String _selectedDomain = ''; + String? _error; + + @override + void initState() { + super.initState(); + _selectedDomain = widget.currentDomain; + _fetchInstances(); + } + + @override + void didUpdateWidget(covariant SettingsScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.currentDomain != widget.currentDomain) { + _selectedDomain = widget.currentDomain; + } + } + + Future _fetchInstances() async { + setState(() { + _loading = true; + _error = null; + }); + + try { + final fetched = await widget.loadInstances(); + if (!mounted) return; + setState(() { + _instances = fetched; + }); + } catch (error) { + if (!mounted) return; + setState(() { + _instances = const []; + _error = error.toString(); + }); + } finally { + if (mounted) { + setState(() { + _loading = false; + }); + } + } + } + + void _onEndpointChanged(String? domain) { + if (domain == null || domain.isEmpty) { + return; + } + + setState(() { + _selectedDomain = domain; + }); + widget.onDomainChanged(domain); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Endpoint set to $domain')), + ); + } + + List> _buildEndpointOptions() { + final seen = {}; + final items = >[]; + + // Always surface the default BerlinMesh endpoint. + seen.add(_defaultDomain); + items.add( + const DropdownMenuItem( + value: _defaultDomain, + child: Text(_defaultName), + ), + ); + + for (final instance in _instances) { + if (instance.domain.isEmpty || seen.contains(instance.domain)) { + continue; + } + seen.add(instance.domain); + items.add( + DropdownMenuItem( + value: instance.domain, + child: Text(instance.displayName), + ), + ); + } + + if (_selectedDomain.isNotEmpty && !seen.contains(_selectedDomain)) { + items.insert( + 0, + DropdownMenuItem( + value: _selectedDomain, + child: Text('Custom ($_selectedDomain)'), + ), + ); + } + + return items; + } @override Widget build(BuildContext context) { + final endpointItems = _buildEndpointOptions(); return Scaffold( - appBar: AppBar(title: const Text('Settings (MVP)')), + appBar: AppBar(title: const Text('Settings')), body: ListView( - children: const [ + children: [ ListTile( - leading: Icon(Icons.cloud), - title: Text('Endpoint'), - subtitle: Text('https://potatomesh.net/api/messages'), + leading: const Icon(Icons.cloud), + title: const Text('Endpoint'), + subtitle: Text('$_selectedDomain/api/messages'), ), - Divider(), - ListTile( + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButtonFormField( + key: ValueKey(_selectedDomain), + initialValue: _selectedDomain.isNotEmpty + ? _selectedDomain + : _defaultDomain, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Select endpoint', + border: OutlineInputBorder(), + ), + items: endpointItems, + onChanged: _loading ? null : _onEndpointChanged, + ), + const SizedBox(height: 8), + if (_loading) + const LinearProgressIndicator() + else if (_error != null) + Text( + 'Failed to load instances: $_error', + style: const TextStyle(color: Colors.redAccent), + ) + else if (_instances.isEmpty) + const Text('No federation instances returned.'), + ], + ), + ), + const Divider(), + const ListTile( leading: Icon(Icons.info_outline), title: Text('About'), subtitle: Text( - 'Meshtastic Reader MVP — read-only view of PotatoMesh messages.'), + 'Meshtastic Reader — read-only view of PotatoMesh messages.'), ), ], ), @@ -349,15 +568,66 @@ class MeshMessage { } } +/// Mesh federation instance metadata used to configure endpoints. +class MeshInstance { + const MeshInstance({ + required this.name, + required this.domain, + }); + + /// Human-friendly instance name. + final String name; + + /// Instance domain hosting the PotatoMesh API. + final String domain; + + /// Prefer the provided name, falling back to the domain. + String get displayName => name.isNotEmpty ? name : domain; + + /// Parse a [MeshInstance] from an API payload entry. + factory MeshInstance.fromJson(Map json) { + final domain = json['domain']?.toString().trim() ?? ''; + final name = json['name']?.toString().trim() ?? ''; + return MeshInstance(name: name, domain: domain); + } +} + +/// Build a messages API URI for a given domain or absolute URL. +Uri _buildMessagesUri(String domain) { + final trimmed = domain.trim(); + if (trimmed.isEmpty) { + return Uri.https('potatomesh.net', '/api/messages', { + 'limit': '100', + 'encrypted': 'false', + }); + } + + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + final parsed = Uri.parse(trimmed); + return parsed.replace( + path: '/api/messages', + queryParameters: { + 'limit': '100', + 'encrypted': 'false', + }, + ); + } + + return Uri.https(trimmed, '/api/messages', { + 'limit': '100', + 'encrypted': 'false', + }); +} + /// Fetches the latest PotatoMesh messages and returns them sorted by receive time. /// /// A custom [client] can be supplied for testing; otherwise a short-lived /// [http.Client] is created and closed after the request completes. -Future> fetchMessages({http.Client? client}) async { - final uri = Uri.https('potatomesh.net', '/api/messages', { - 'limit': '100', - 'encrypted': 'false', - }); +Future> fetchMessages({ + http.Client? client, + String domain = 'potatomesh.net', +}) async { + final uri = _buildMessagesUri(domain); final httpClient = client ?? http.Client(); final shouldClose = client == null; @@ -383,6 +653,42 @@ Future> fetchMessages({http.Client? client}) async { return sortMessagesByRxTime(msgs); } +/// Fetches federation instance metadata from potatomesh.net and normalizes it. +/// +/// Instances lacking a domain are dropped. A provided [client] is closed +/// automatically when created internally. +Future> fetchInstances({http.Client? client}) async { + final uri = Uri.https('potatomesh.net', '/api/instances'); + final httpClient = client ?? http.Client(); + final shouldClose = client == null; + + try { + final resp = await httpClient.get(uri); + if (resp.statusCode != 200) { + throw Exception('HTTP ${resp.statusCode}: ${resp.body}'); + } + + final dynamic decoded = jsonDecode(resp.body); + if (decoded is! List) { + throw Exception('Unexpected instances response, expected JSON array'); + } + + final instances = decoded + .whereType>() + .map((entry) => MeshInstance.fromJson(entry)) + .where((instance) => instance.domain.isNotEmpty) + .toList() + ..sort((a, b) => + a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase())); + + return instances; + } finally { + if (shouldClose) { + httpClient.close(); + } + } +} + /// Returns a new list sorted by receive time so older messages render first. /// /// Messages that lack a receive time keep their original positions to avoid diff --git a/app/pubspec.lock b/app/pubspec.lock index b1ce71c..29e4b9a 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -122,18 +122,18 @@ packages: dependency: "direct dev" description: name: flutter_launcher_icons - sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" url: "https://pub.dev" source: hosted - version: "0.13.1" + version: "0.14.4" flutter_lints: dependency: "direct dev" description: name: flutter_lints - sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "6.0.0" flutter_native_splash: dependency: "direct dev" description: @@ -220,10 +220,10 @@ packages: dependency: transitive description: name: lints - sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "6.0.0" matcher: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 7d44806..3c54c93 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -14,8 +14,8 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^3.0.2 - flutter_launcher_icons: ^0.13.1 + flutter_lints: ^6.0.0 + flutter_launcher_icons: ^0.14.4 flutter_native_splash: ^2.4.1 flutter: diff --git a/app/test/mesh_message_test.dart b/app/test/mesh_message_test.dart index 55bb489..a2dd054 100644 --- a/app/test/mesh_message_test.dart +++ b/app/test/mesh_message_test.dart @@ -183,5 +183,52 @@ void main() { throwsA(isA()), ); }); + + test('uses custom domains including full URLs', () async { + final calls = []; + final client = MockClient((request) async { + calls.add(request.url); + return http.Response(jsonEncode([]), 200); + }); + + await fetchMessages(client: client, domain: 'mesh.example.org'); + await fetchMessages( + client: client, domain: 'https://mesh.alt.example/api'); + + expect(calls[0].host, 'mesh.example.org'); + expect(calls[0].path, '/api/messages'); + expect(calls[1].scheme, 'https'); + expect(calls[1].path, '/api/messages'); + }); + }); + + group('fetchInstances', () { + test('parses and sorts instance list', () async { + final client = MockClient((request) async { + return http.Response( + jsonEncode([ + {'name': 'Bravo', 'domain': 'bravo.example'}, + {'name': 'Alpha', 'domain': 'alpha.example'}, + {'name': '', 'domain': ''}, + ]), + 200, + ); + }); + + final instances = await fetchInstances(client: client); + + expect(instances.map((i) => i.displayName), ['Alpha', 'Bravo']); + expect( + instances.map((i) => i.domain), ['alpha.example', 'bravo.example']); + }); + + test('throws on failed fetch', () async { + final client = MockClient((request) async => http.Response('oops', 500)); + + expect( + () => fetchInstances(client: client), + throwsA(isA()), + ); + }); }); } diff --git a/app/test/messages_screen_test.dart b/app/test/messages_screen_test.dart index 6c6d0e0..f9b49f7 100644 --- a/app/test/messages_screen_test.dart +++ b/app/test/messages_screen_test.dart @@ -16,6 +16,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; import 'package:potato_mesh_reader/main.dart'; /// Widget-level tests that exercise UI states and rendering branches. @@ -23,7 +24,10 @@ void main() { testWidgets('PotatoMeshReaderApp wires theming and home screen', (tester) async { final fetchCalls = []; - Future> fakeFetch() async { + Future> fakeFetch({ + http.Client? client, + String domain = 'potatomesh.net', + }) async { fetchCalls.add(1); return [ MeshMessage( @@ -134,15 +138,83 @@ void main() { testWidgets('Settings button navigates to SettingsScreen', (tester) async { await tester.pumpWidget( MaterialApp( - home: MessagesScreen(fetcher: () async => []), + home: MessagesScreen( + fetcher: () async => [], + onOpenSettings: (context) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SettingsScreen( + currentDomain: 'potatomesh.net', + onDomainChanged: (_) {}, + loadInstances: () async => const [], + ), + ), + ); + }, + resetToken: 0, + ), ), ); await tester.tap(find.byIcon(Icons.settings)); await tester.pumpAndSettle(); - expect(find.text('Settings (MVP)'), findsOneWidget); - expect(find.textContaining('Meshtastic Reader MVP'), findsOneWidget); + expect(find.text('Settings'), findsOneWidget); + expect(find.textContaining('Meshtastic Reader'), findsOneWidget); + }); + + testWidgets('changing endpoint triggers a refresh with new domain', + (tester) async { + final calls = []; + Future> fetcher({ + http.Client? client, + String domain = 'potatomesh.net', + }) async { + calls.add(domain); + return [ + MeshMessage( + id: 1, + rxTime: null, + rxIso: '2024-01-01T00:00:00Z', + fromId: '!a', + toId: '^', + channel: 1, + channelName: 'Main', + portnum: 'TEXT', + text: domain, + rssi: null, + snr: null, + hopLimit: null, + ) + ]; + } + + Future> loader() async => const [ + MeshInstance(name: 'Mesh Berlin', domain: 'berlin.mesh'), + ]; + + await tester.pumpWidget( + PotatoMeshReaderApp( + fetcher: fetcher, + instanceFetcher: ({http.Client? client}) => loader(), + ), + ); + await tester.pumpAndSettle(); + + expect(calls.single, 'potatomesh.net'); + expect(find.text('potatomesh.net'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.settings)); + await tester.pumpAndSettle(); + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Mesh Berlin').last); + await tester.pumpAndSettle(); + await tester.pageBack(); + await tester.pumpAndSettle(); + + expect(calls.last, 'berlin.mesh'); + expect(find.text('berlin.mesh'), findsOneWidget); }); testWidgets('ChatLine renders placeholders and nick colour', (tester) async { diff --git a/app/test/settings_screen_test.dart b/app/test/settings_screen_test.dart new file mode 100644 index 0000000..c722ef1 --- /dev/null +++ b/app/test/settings_screen_test.dart @@ -0,0 +1,67 @@ +// Copyright © 2025-26 l5yth & contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:potato_mesh_reader/main.dart'; + +void main() { + testWidgets('SettingsScreen lists instances and updates selection', + (tester) async { + final selections = []; + Future> loader() async => const [ + MeshInstance(name: 'Mesh Dresden', domain: 'map.meshdresden.eu'), + MeshInstance(name: 'Mesh Berlin', domain: 'berlin.mesh'), + ]; + + await tester.pumpWidget( + MaterialApp( + home: SettingsScreen( + currentDomain: 'potatomesh.net', + onDomainChanged: selections.add, + loadInstances: loader, + ), + ), + ); + + await tester.pumpAndSettle(); + + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + expect(find.text('Mesh Dresden'), findsOneWidget); + await tester.tap(find.text('Mesh Berlin').last); + await tester.pumpAndSettle(); + + expect(selections.single, 'berlin.mesh'); + expect(find.textContaining('berlin.mesh'), findsWidgets); + }); + + testWidgets('SettingsScreen surfaces load errors', (tester) async { + Future> loader() => Future.error(StateError('boom')); + + await tester.pumpWidget( + MaterialApp( + home: SettingsScreen( + currentDomain: 'potatomesh.net', + onDomainChanged: (_) {}, + loadInstances: loader, + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.textContaining('Failed to load instances'), findsOneWidget); + }); +} diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index 88b628a..ee9853f 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -6,6 +6,7 @@ // tree, read text, and verify that the values of widget properties are correct. import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; import 'package:potato_mesh_reader/main.dart'; @@ -44,7 +45,10 @@ void main() { ]; var fetchCount = 0; - Future> mockFetcher() async { + Future> mockFetcher({ + http.Client? client, + String domain = 'potatomesh.net', + }) async { final idx = fetchCount >= sampleMessages.length ? sampleMessages.length - 1 : fetchCount;