app: add instance selector to settings (#497)

* app: add instance selector to settings

* app: add instance selector to settings
This commit is contained in:
l5y
2025-11-22 23:15:12 +01:00
committed by GitHub
parent 356f60d02f
commit 8939911ce1
7 changed files with 534 additions and 38 deletions

View File

@@ -21,15 +21,59 @@ void main() {
runApp(const PotatoMeshReaderApp());
}
/// Function type used to fetch messages from a specific endpoint.
typedef MessageFetcher = Future<List<MeshMessage>> 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<List<MeshMessage>> Function() fetcher;
final MessageFetcher fetcher;
/// Loader for federation instance metadata, overridable in tests.
final Future<List<MeshInstance>> Function({http.Client? client})
instanceFetcher;
/// Initial endpoint domain used when the app boots.
final String initialDomain;
@override
State<PotatoMeshReaderApp> createState() => _PotatoMeshReaderAppState();
}
class _PotatoMeshReaderAppState extends State<PotatoMeshReaderApp> {
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<List<MeshMessage>> _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<String>(_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<List<MeshMessage>> 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<MessagesScreen> createState() => _MessagesScreenState();
}
@@ -84,7 +151,8 @@ class _MessagesScreenState extends State<MessagesScreen> {
@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<MessagesScreen> {
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<String> onDomainChanged;
/// Loader used to fetch federation instance metadata.
final Future<List<MeshInstance>> Function() loadInstances;
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
static const String _defaultDomain = 'potatomesh.net';
static const String _defaultName = 'BerlinMesh';
List<MeshInstance> _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<void> _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<DropdownMenuItem<String>> _buildEndpointOptions() {
final seen = <String>{};
final items = <DropdownMenuItem<String>>[];
// 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<String>(
key: ValueKey<String>(_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<String, dynamic> 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<List<MeshMessage>> fetchMessages({http.Client? client}) async {
final uri = Uri.https('potatomesh.net', '/api/messages', {
'limit': '100',
'encrypted': 'false',
});
Future<List<MeshMessage>> 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<List<MeshMessage>> 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<List<MeshInstance>> 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<String, dynamic>>()
.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

View File

@@ -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:

View File

@@ -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:

View File

@@ -183,5 +183,52 @@ void main() {
throwsA(isA<Exception>()),
);
});
test('uses custom domains including full URLs', () async {
final calls = <Uri>[];
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<Exception>()),
);
});
});
}

View File

@@ -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 = <int>[];
Future<List<MeshMessage>> fakeFetch() async {
Future<List<MeshMessage>> 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 = <String>[];
Future<List<MeshMessage>> 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<List<MeshInstance>> 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<String>));
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 {

View File

@@ -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 = <String>[];
Future<List<MeshInstance>> 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<String>));
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<List<MeshInstance>> 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);
});
}

View File

@@ -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<List<MeshMessage>> mockFetcher() async {
Future<List<MeshMessage>> mockFetcher({
http.Client? client,
String domain = 'potatomesh.net',
}) async {
final idx = fetchCount >= sampleMessages.length
? sampleMessages.length - 1
: fetchCount;