mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-03-28 17:42:48 +01:00
app: add instance selector to settings (#497)
* app: add instance selector to settings * app: add instance selector to settings
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
67
app/test/settings_screen_test.dart
Normal file
67
app/test/settings_screen_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user