app: instance and chat mvp (#498)

* app: instance and chat mvp

* app: instance and chat mvp

* app: address review comments

* cover missing unit test vectors

* app: add backlink to github
This commit is contained in:
l5y
2025-11-23 01:32:43 +01:00
committed by GitHub
parent 8939911ce1
commit 753c0f171f
12 changed files with 1336 additions and 121 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
app/assets/icon-splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 39 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 65 KiB

15
app/debug.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
export GIT_TAG="$(git describe --tags --abbrev=0)"
export GIT_COMMITS="$(git rev-list --count ${GIT_TAG}..HEAD)"
export GIT_SHA="$(git rev-parse --short=9 HEAD)"
export GIT_DIRTY="$(git diff --quiet --ignore-submodules HEAD || echo true || echo false)"
flutter clean
flutter pub get
flutter run \
--dart-define=GIT_TAG="${GIT_TAG}" \
--dart-define=GIT_COMMITS="${GIT_COMMITS}" \
--dart-define=GIT_SHA="${GIT_SHA}" \
--dart-define=GIT_DIRTY="${GIT_DIRTY}" \
--device-id 38151FDJH00D4C

View File

@@ -12,10 +12,24 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import 'dart:async';
import 'dart:convert';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:package_info_plus/package_info_plus.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:url_launcher/url_launcher.dart';
const String _gitVersionEnv =
String.fromEnvironment('GIT_VERSION', defaultValue: '');
const String _gitTagEnv = String.fromEnvironment('GIT_TAG', defaultValue: '');
const String _gitCommitsEnv =
String.fromEnvironment('GIT_COMMITS', defaultValue: '');
const String _gitShaEnv = String.fromEnvironment('GIT_SHA', defaultValue: '');
const String _gitDirtyEnv =
String.fromEnvironment('GIT_DIRTY', defaultValue: '');
void main() {
runApp(const PotatoMeshReaderApp());
@@ -27,7 +41,7 @@ typedef MessageFetcher = Future<List<MeshMessage>> Function({
String domain,
});
/// Meshtastic Reader root widget that configures theming and the home screen.
/// PotatoMesh Reader root widget that configures theming and the home screen.
class PotatoMeshReaderApp extends StatefulWidget {
const PotatoMeshReaderApp({
super.key,
@@ -78,7 +92,7 @@ class _PotatoMeshReaderAppState extends State<PotatoMeshReaderApp> {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Meshtastic Reader',
title: '🥔 PotatoMesh Reader',
debugShowCheckedModeBanner: false,
theme: ThemeData(
brightness: Brightness.dark,
@@ -99,6 +113,7 @@ class _PotatoMeshReaderAppState extends State<PotatoMeshReaderApp> {
key: ValueKey<String>(_endpointDomain),
fetcher: _fetchMessagesForCurrentDomain,
resetToken: _endpointVersion,
domain: _endpointDomain,
onOpenSettings: (context) {
Navigator.of(context).push(
MaterialPageRoute(
@@ -122,6 +137,7 @@ class MessagesScreen extends StatefulWidget {
this.fetcher = fetchMessages,
this.onOpenSettings,
this.resetToken = 0,
required this.domain,
});
/// Fetch function used to load messages from the PotatoMesh API.
@@ -133,17 +149,31 @@ class MessagesScreen extends StatefulWidget {
/// Bumps when the endpoint changes to force a refresh of cached data.
final int resetToken;
/// Active endpoint domain used for auxiliary lookups like node metadata.
final String domain;
@override
State<MessagesScreen> createState() => _MessagesScreenState();
}
class _MessagesScreenState extends State<MessagesScreen> {
class _MessagesScreenState extends State<MessagesScreen>
with WidgetsBindingObserver {
late Future<List<MeshMessage>> _future;
List<MeshMessage> _messages = const [];
final ScrollController _scrollController = ScrollController();
Timer? _refreshTimer;
bool _isForeground = true;
int _fetchVersion = 0;
@override
void initState() {
super.initState();
_future = widget.fetcher();
_startFetch(clear: true);
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
_refresh();
_startAutoRefresh();
});
}
/// When the fetcher changes, reload the future so the widget reflects the
@@ -153,9 +183,31 @@ class _MessagesScreenState extends State<MessagesScreen> {
super.didUpdateWidget(oldWidget);
if (oldWidget.fetcher != widget.fetcher ||
oldWidget.resetToken != widget.resetToken) {
setState(() {
_future = widget.fetcher();
});
_restartAutoRefresh();
_startFetch(clear: true);
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_refreshTimer?.cancel();
_scrollController.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final nowForeground = state == AppLifecycleState.resumed ||
state == AppLifecycleState.inactive;
if (nowForeground != _isForeground) {
_isForeground = nowForeground;
if (_isForeground) {
_refresh();
_startAutoRefresh();
} else {
_refreshTimer?.cancel();
}
}
}
@@ -165,21 +217,102 @@ class _MessagesScreenState extends State<MessagesScreen> {
/// via its `snapshot.error` state without bubbling an exception to the
/// gesture handler.
Future<void> _refresh() async {
_startFetch();
}
void _appendMessages(List<MeshMessage> newMessages) {
if (newMessages.isEmpty) return;
final existingKeys = _messages.map(_messageKey).toSet();
var added = 0;
final combined = List<MeshMessage>.from(_messages);
for (final msg in newMessages) {
final key = _messageKey(msg);
if (existingKeys.contains(key)) continue;
combined.add(msg);
existingKeys.add(key);
added += 1;
}
if (added == 0 && _messages.isNotEmpty) {
_scheduleScrollToBottom();
return;
}
setState(() {
_messages = combined;
});
_scheduleScrollToBottom();
}
String _messageKey(MeshMessage msg) {
return '${msg.id}-${msg.rxIso}-${msg.text}';
}
void _scheduleScrollToBottom({int retries = 5}) {
if (retries <= 0) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_scrollController.hasClients) {
_scheduleScrollToBottom(retries: retries - 1);
return;
}
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
});
}
void _startAutoRefresh() {
_refreshTimer?.cancel();
if (!_isForeground) return;
_refreshTimer =
Timer.periodic(const Duration(seconds: 60), (_) => _refresh());
}
void _restartAutoRefresh() {
if (_isForeground) {
_startAutoRefresh();
}
}
void _startFetch({bool clear = false}) {
final version = ++_fetchVersion;
setState(() {
if (clear) {
_messages = const [];
}
_future = widget.fetcher();
});
try {
await _future;
} catch (_) {
// Let the FutureBuilder display error UI without breaking the gesture.
_future.then((msgs) {
if (version != _fetchVersion) return;
_appendMessages(msgs);
}).catchError((_) {
// Let FutureBuilder surface the error; ignore for stale fetches.
});
}
String _dateLabelFor(MeshMessage message) {
if (message.rxTime != null) {
final local = message.rxTime!.toLocal();
final y = local.year.toString().padLeft(4, '0');
final m = local.month.toString().padLeft(2, '0');
final d = local.day.toString().padLeft(2, '0');
return '$y-$m-$d';
}
if (message.rxIso.isNotEmpty && message.rxIso.length >= 10) {
return message.rxIso.substring(0, 10);
}
return 'Unknown';
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Meshtastic Reader'),
leading: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: SvgPicture.asset(
'assets/potatomesh-logo.svg',
height: 28,
semanticsLabel: 'PotatoMesh logo',
),
),
title: const Text('🥔 PotatoMesh Reader'),
actions: [
IconButton(
tooltip: 'Refresh',
@@ -200,10 +333,11 @@ class _MessagesScreenState extends State<MessagesScreen> {
body: FutureBuilder<List<MeshMessage>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
if (snapshot.connectionState == ConnectionState.waiting &&
_messages.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
if (snapshot.hasError && _messages.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -214,20 +348,44 @@ class _MessagesScreenState extends State<MessagesScreen> {
),
);
}
final messages = snapshot.data ?? [];
final messages = _messages;
if (messages.isEmpty) {
return const Center(child: Text('No messages yet.'));
}
return RefreshIndicator(
onRefresh: _refresh,
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: messages.length,
itemBuilder: (context, index) {
final msg = messages[index];
return ChatLine(message: msg);
},
child: ScrollConfiguration(
behavior: const ScrollBehavior().copyWith(scrollbars: false),
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: messages.length,
itemBuilder: (context, index) {
final msg = messages[index];
final currentLabel = _dateLabelFor(msg);
final prevLabel =
index > 0 ? _dateLabelFor(messages[index - 1]) : null;
final needsDivider =
prevLabel == null || currentLabel != prevLabel;
if (!needsDivider) {
return ChatLine(
message: msg,
domain: widget.domain,
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DateDivider(label: currentLabel),
ChatLine(
message: msg,
domain: widget.domain,
),
],
);
},
),
),
);
},
@@ -238,10 +396,15 @@ class _MessagesScreenState extends State<MessagesScreen> {
/// Individual chat line styled in IRC-inspired format.
class ChatLine extends StatelessWidget {
const ChatLine({super.key, required this.message});
const ChatLine({
super.key,
required this.message,
required this.domain,
});
/// Message data to render.
final MeshMessage message;
final String domain;
/// Generates a stable color from the nickname characters by hashing to a hue.
Color _nickColor(String nick) {
@@ -249,52 +412,166 @@ class ChatLine extends StatelessWidget {
return HSLColor.fromAHSL(1, h.toDouble(), 0.5, 0.6).toColor();
}
List<TextSpan> _buildLinkedSpans(
String text,
TextStyle baseStyle,
TextStyle linkStyle,
) {
final spans = <TextSpan>[];
final urlPattern = RegExp(r'(https?:\/\/[^\s]+)');
int start = 0;
for (final match in urlPattern.allMatches(text)) {
if (match.start > start) {
spans.add(TextSpan(
text: text.substring(start, match.start),
style: baseStyle,
));
}
final urlText = match.group(0) ?? '';
final uri = Uri.tryParse(urlText);
spans.add(TextSpan(
text: urlText,
style: linkStyle,
recognizer: TapGestureRecognizer()
..onTap = () async {
if (uri != null) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
},
));
start = match.end;
}
if (start < text.length) {
spans.add(TextSpan(
text: text.substring(start),
style: baseStyle,
));
}
if (spans.isEmpty) {
spans.add(TextSpan(text: text, style: baseStyle));
}
return spans;
}
String _fallbackShortName(String fromId) {
return NodeShortNameCache.fallbackShortName(fromId);
}
double _computeIndentPixels(TextStyle baseStyle, BuildContext context) {
final painter = TextPainter(
text: TextSpan(text: ' ', style: baseStyle),
textDirection: Directionality.of(context),
)..layout();
return painter.size.width * 8;
}
@override
Widget build(BuildContext context) {
final timeStr = '[${message.timeFormatted}]';
final nick = '<${message.fromShort}>';
final rawId = message.fromId.isNotEmpty ? message.fromId : '?';
final nick = rawId.startsWith('!') ? rawId : '!$rawId';
final channel = '#${message.channelName ?? ''}'.trim();
final bodyText = message.text.isEmpty ? '⟂ (no text)' : message.text;
final baseStyle = DefaultTextStyle.of(context).style;
final linkStyle = baseStyle.copyWith(
color: Colors.tealAccent,
decoration: TextDecoration.underline,
);
final indentPx = _computeIndentPixels(baseStyle, context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
timeStr,
style: const TextStyle(
color: Colors.grey,
fontWeight: FontWeight.w500,
),
child: FutureBuilder<String>(
future: NodeShortNameCache.instance.shortNameFor(
domain: domain,
nodeId: rawId,
),
const SizedBox(width: 6),
Text(
nick,
style: TextStyle(
color: _nickColor(message.fromShort),
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
message.text.isEmpty ? '⟂ (no text)' : message.text,
style: DefaultTextStyle.of(context).style,
),
if (message.channelName != null)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
'#${message.channelName}',
style: const TextStyle(color: Colors.tealAccent),
builder: (context, snapshot) {
final shortName = snapshot.data?.isNotEmpty == true
? snapshot.data!
: _fallbackShortName(rawId);
final paddedShortName = NodeShortNameCache.padToWidth(shortName);
return SelectionArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text.rich(
TextSpan(
children: [
TextSpan(
text: timeStr,
style: const TextStyle(
color: Colors.grey,
fontWeight: FontWeight.w500,
),
),
const TextSpan(text: ' '),
TextSpan(
text: '<$nick>',
style: TextStyle(
color: _nickColor(message.fromShort),
fontWeight: FontWeight.w600,
),
),
const TextSpan(text: ' '),
TextSpan(
text: '($paddedShortName)',
style: baseStyle.copyWith(
color: _nickColor(message.fromShort),
fontWeight: FontWeight.w600,
),
),
const TextSpan(text: ' '),
TextSpan(
text: channel,
style: const TextStyle(color: Colors.tealAccent),
),
],
style: baseStyle,
),
),
],
),
),
],
const SizedBox(height: 2),
Padding(
padding: EdgeInsets.only(left: indentPx),
child: SelectableText.rich(
TextSpan(
children: _buildLinkedSpans(
bodyText,
baseStyle,
linkStyle,
),
),
),
),
],
),
);
}),
);
}
}
/// Bold, grey date divider between chat messages.
class DateDivider extends StatelessWidget {
const DateDivider({super.key, required this.label});
final String label;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: Text(
'-- $label --',
style: const TextStyle(
fontWeight: FontWeight.w700,
color: Colors.grey,
),
),
);
}
@@ -329,12 +606,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _loading = false;
String _selectedDomain = '';
String? _error;
String _versionLabel = '';
Future<InstanceVersion?>? _instanceVersionFuture;
@override
void initState() {
super.initState();
_selectedDomain = widget.currentDomain;
_fetchInstances();
_loadVersion();
_instanceVersionFuture =
InstanceVersionCache.instance.fetch(domain: _selectedDomain);
}
@override
@@ -342,6 +624,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
super.didUpdateWidget(oldWidget);
if (oldWidget.currentDomain != widget.currentDomain) {
_selectedDomain = widget.currentDomain;
_instanceVersionFuture =
InstanceVersionCache.instance.fetch(domain: _selectedDomain);
}
}
@@ -372,6 +656,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
Future<void> _loadVersion() async {
try {
final info = await PackageInfo.fromPlatform();
final label = _composeGitAwareVersion(info);
if (!mounted) return;
setState(() {
_versionLabel = label;
});
} catch (_) {
if (!mounted) return;
setState(() {
_versionLabel = 'v0.0.0';
});
}
}
void _onEndpointChanged(String? domain) {
if (domain == null || domain.isEmpty) {
return;
@@ -381,6 +681,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
_selectedDomain = domain;
});
widget.onDomainChanged(domain);
setState(() {
_instanceVersionFuture =
InstanceVersionCache.instance.fetch(domain: _selectedDomain);
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Endpoint set to $domain')),
);
@@ -468,12 +772,90 @@ class _SettingsScreenState extends State<SettingsScreen> {
],
),
),
FutureBuilder<InstanceVersion?>(
key: ValueKey<String>(_selectedDomain),
future: _instanceVersionFuture,
builder: (context, snapshot) {
final info = snapshot.data;
final domainDisplay = _selectedDomain.trim().isEmpty
? 'potatomesh.net'
: _selectedDomain.trim();
final domainUri = _buildDomainUrl(domainDisplay);
Widget subtitle;
if (snapshot.connectionState == ConnectionState.waiting) {
subtitle = const Text('Loading version info…');
} else if (info != null) {
subtitle = Text(info.summary);
} else {
subtitle = const Text('Version info unavailable');
}
return ListTile(
leading: const Icon(Icons.storage),
title: const Text('PotatoMesh Info'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
subtitle,
const SizedBox(height: 4),
RichText(
text: TextSpan(
text: domainDisplay,
style: const TextStyle(
color: Colors.tealAccent,
decoration: TextDecoration.underline,
),
recognizer: TapGestureRecognizer()
..onTap = () async {
if (domainUri != null) {
await launchUrl(
domainUri,
mode: LaunchMode.externalApplication,
);
}
},
),
),
],
),
);
},
),
const Divider(),
const ListTile(
leading: Icon(Icons.info_outline),
title: Text('About'),
subtitle: Text(
'Meshtastic Reader read-only view of PotatoMesh messages.'),
'🥔 PotatoMesh Reader - a read-only view of a selected Meshtastic region.'),
),
ListTile(
leading: const Icon(Icons.tag),
title: const Text('Version'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_versionLabel.isNotEmpty ? _versionLabel : 'Loading…'),
const SizedBox(height: 4),
RichText(
text: TextSpan(
text: 'github.com/l5yth/potato-mesh',
style: const TextStyle(
color: Colors.tealAccent,
decoration: TextDecoration.underline,
),
recognizer: TapGestureRecognizer()
..onTap = () async {
final uri = Uri.parse(
'https://github.com/l5yth/potato-mesh/',
);
await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
},
),
),
],
),
),
],
),
@@ -597,7 +979,7 @@ Uri _buildMessagesUri(String domain) {
final trimmed = domain.trim();
if (trimmed.isEmpty) {
return Uri.https('potatomesh.net', '/api/messages', {
'limit': '100',
'limit': '1000',
'encrypted': 'false',
});
}
@@ -607,18 +989,100 @@ Uri _buildMessagesUri(String domain) {
return parsed.replace(
path: '/api/messages',
queryParameters: {
'limit': '100',
'limit': '1000',
'encrypted': 'false',
},
);
}
return Uri.https(trimmed, '/api/messages', {
'limit': '100',
'limit': '1000',
'encrypted': 'false',
});
}
/// Build a node metadata API URI for a given domain.
Uri _buildNodeUri(String domain, String nodeId) {
final trimmedDomain = domain.trim();
final encodedId = Uri.encodeComponent(nodeId);
if (trimmedDomain.isEmpty) {
return Uri.https('potatomesh.net', '/api/nodes/$encodedId');
}
if (trimmedDomain.startsWith('http://') ||
trimmedDomain.startsWith('https://')) {
final parsed = Uri.parse(trimmedDomain);
return parsed.replace(path: '/api/nodes/$encodedId');
}
return Uri.https(trimmedDomain, '/api/nodes/$encodedId');
}
/// Build a /version endpoint URI for a given domain.
Uri _buildVersionUri(String domain) {
final trimmed = domain.trim();
if (trimmed.isEmpty) {
return Uri.https('potatomesh.net', '/version');
}
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
final parsed = Uri.parse(trimmed);
return parsed.replace(path: '/version');
}
return Uri.https(trimmed, '/version');
}
String _composeGitAwareVersion(PackageInfo info) {
const versionDefine = _gitVersionEnv;
if (versionDefine.isNotEmpty) {
return versionDefine.startsWith('v') ? versionDefine : 'v$versionDefine';
}
const tagDefine = _gitTagEnv;
if (tagDefine.isNotEmpty) {
final tag = tagDefine.startsWith('v') ? tagDefine : 'v$tagDefine';
final suffixParts = <String>[];
const commitsDefine = _gitCommitsEnv;
const shaDefine = _gitShaEnv;
const dirtyDefine = _gitDirtyEnv;
final commits = commitsDefine.trim();
final sha = shaDefine.trim();
final dirtyFlag = dirtyDefine.toLowerCase().trim();
final dirty = dirtyFlag == 'true' || dirtyFlag == '1' || dirtyFlag == 'yes';
if (commits.isNotEmpty && commits != '0') {
suffixParts.add(commits);
if (sha.isNotEmpty) {
suffixParts.add(sha);
}
} else if (sha.isNotEmpty) {
suffixParts.add(sha);
}
if (dirty) {
if (suffixParts.isEmpty) {
suffixParts.add('dirty');
} else {
suffixParts[suffixParts.length - 1] = '${suffixParts.last}-dirty';
}
}
return suffixParts.isEmpty ? tag : '$tag+${suffixParts.join('-')}';
}
final base = 'v${info.version}';
return info.buildNumber.isNotEmpty ? '$base+${info.buildNumber}' : base;
}
Uri? _buildDomainUrl(String domain) {
final trimmed = domain.trim();
if (trimmed.isEmpty) return null;
final hasScheme =
trimmed.startsWith('http://') || trimmed.startsWith('https://');
final candidate = hasScheme ? trimmed : 'https://$trimmed';
return Uri.tryParse(candidate);
}
/// Fetches the latest PotatoMesh messages and returns them sorted by receive time.
///
/// A custom [client] can be supplied for testing; otherwise a short-lived
@@ -653,6 +1117,174 @@ Future<List<MeshMessage>> fetchMessages({
return sortMessagesByRxTime(msgs);
}
/// Memoised loader for node short names sourced from the API.
class NodeShortNameCache {
NodeShortNameCache._();
/// Singleton instance used by chat line rendering.
static final NodeShortNameCache instance = NodeShortNameCache._();
final Map<String, Future<String>> _cache = {};
/// Resolve the short name for a node, defaulting to the fallback suffix.
Future<String> shortNameFor({
required String domain,
required String nodeId,
http.Client? client,
}) {
final trimmedId = nodeId.trim();
final fallback = fallbackShortName(trimmedId);
if (trimmedId.isEmpty) return Future.value(fallback);
final key = '${domain.trim()}|$trimmedId';
if (_cache.containsKey(key)) {
return _cache[key]!;
}
final future = _loadShortName(
domain: domain,
nodeId: trimmedId,
fallback: fallback,
client: client,
);
_cache[key] = future;
return future;
}
Future<String> _loadShortName({
required String domain,
required String nodeId,
required String fallback,
http.Client? client,
}) async {
final uri = _buildNodeUri(domain, nodeId);
final httpClient = client ?? http.Client();
final shouldClose = client == null;
try {
final resp = await httpClient.get(uri);
if (resp.statusCode != 200) return fallback;
final dynamic decoded = jsonDecode(resp.body);
if (decoded is Map<String, dynamic>) {
final raw = decoded['short_name'] ?? decoded['shortName'];
if (raw != null) {
final name = raw.toString().trim();
if (name.isNotEmpty) return name;
}
}
return fallback;
} catch (_) {
return fallback;
} finally {
if (shouldClose) {
httpClient.close();
}
}
}
/// Fallback that uses the trailing four characters of the node id.
static String fallbackShortName(String fromId) {
final trimmed = fromId.startsWith('!') ? fromId.substring(1) : fromId;
if (trimmed.isEmpty) return '????';
final suffix =
trimmed.length <= 4 ? trimmed : trimmed.substring(trimmed.length - 4);
return padToWidth(suffix);
}
/// Ensures the provided short name is at least [width] characters wide.
static String padToWidth(String value, {int width = 4}) {
if (value.length >= width) return value;
return value.padLeft(width);
}
}
/// Cached metadata describing an instance's public version payload.
class InstanceVersion {
const InstanceVersion({
required this.name,
required this.channel,
required this.frequency,
required this.instanceDomain,
});
final String name;
final String? channel;
final String? frequency;
final String? instanceDomain;
String get summary {
final parts = <String>[];
if (name.isNotEmpty) parts.add(name);
if (channel != null && channel!.isNotEmpty) parts.add(channel!);
if (frequency != null && frequency!.isNotEmpty) parts.add(frequency!);
return parts.isNotEmpty ? parts.join(' · ') : 'Unknown';
}
factory InstanceVersion.fromJson(Map<String, dynamic> json) {
final config = json['config'] is Map<String, dynamic>
? json['config'] as Map<String, dynamic>
: <String, dynamic>{};
final siteName = config['siteName']?.toString().trim() ?? '';
final name = (json['name']?.toString().trim() ?? '').isNotEmpty
? json['name'].toString().trim()
: siteName;
return InstanceVersion(
name: name,
channel: config['channel']?.toString().trim(),
frequency: config['frequency']?.toString().trim(),
instanceDomain: config['instanceDomain']?.toString().trim(),
);
}
}
/// Memoised loader for instance version payloads.
class InstanceVersionCache {
InstanceVersionCache._();
static final InstanceVersionCache instance = InstanceVersionCache._();
final Map<String, Future<InstanceVersion?>> _cache = {};
Future<InstanceVersion?> fetch({
required String domain,
http.Client? client,
}) {
final key = domain.trim().isEmpty ? 'potatomesh.net' : domain.trim();
if (_cache.containsKey(key)) {
return _cache[key]!;
}
final future = _load(key, client: client);
_cache[key] = future;
return future;
}
Future<InstanceVersion?> _load(
String domain, {
http.Client? client,
}) async {
final uri = _buildVersionUri(domain);
final httpClient = client ?? http.Client();
final shouldClose = client == null;
try {
final resp = await httpClient.get(uri);
if (resp.statusCode != 200) return null;
final dynamic decoded = jsonDecode(resp.body);
if (decoded is Map<String, dynamic>) {
return InstanceVersion.fromJson(decoded);
}
return null;
} catch (_) {
return null;
} finally {
if (shouldClose) {
httpClient.close();
}
}
}
}
/// Fetches federation instance metadata from potatomesh.net and normalizes it.
///
/// Instances lacking a domain are dropped. A provided [client] is closed

View File

@@ -142,6 +142,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.7"
flutter_svg:
dependency: "direct main"
description:
name: flutter_svg
sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95"
url: "https://pub.dev"
source: hosted
version: "2.2.3"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -248,6 +256,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.17.0"
package_info_plus:
dependency: "direct main"
description:
name: package_info_plus
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
url: "https://pub.dev"
source: hosted
version: "8.3.1"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
path:
dependency: transitive
description:
@@ -256,6 +280,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
petitparser:
dependency: transitive
description:
@@ -264,6 +296,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
posix:
dependency: transitive
description:
@@ -341,6 +381,94 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.1"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
url: "https://pub.dev"
source: hosted
version: "6.3.28"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad
url: "https://pub.dev"
source: hosted
version: "6.3.6"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
vector_graphics:
dependency: transitive
description:
name: vector_graphics
sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6
url: "https://pub.dev"
source: hosted
version: "1.1.19"
vector_graphics_codec:
dependency: transitive
description:
name: vector_graphics_codec
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
url: "https://pub.dev"
source: hosted
version: "1.1.13"
vector_graphics_compiler:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc
url: "https://pub.dev"
source: hosted
version: "1.1.19"
vector_math:
dependency: transitive
description:
@@ -365,6 +493,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "5.15.0"
xml:
dependency: transitive
description:
@@ -382,5 +518,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.8.0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"
dart: ">=3.9.0 <4.0.0"
flutter: ">=3.35.0"

View File

@@ -10,6 +10,9 @@ dependencies:
flutter:
sdk: flutter
http: ^1.2.0
package_info_plus: ^8.1.0
flutter_svg: ^2.0.10+1
url_launcher: ^6.3.1
dev_dependencies:
flutter_test:
@@ -26,14 +29,14 @@ flutter:
flutter_launcher_icons:
android: true
ios: true
image_path: assets/icon.png
image_path: assets/icon-launcher.png
remove_alpha_ios: true
adaptive_icon_background: "#111417"
adaptive_icon_foreground: assets/icon.png
adaptive_icon_foreground: assets/icon-launcher.png
flutter_native_splash:
color: "#111417"
image: assets/icon.png
image: assets/icon-splash.png
android_12:
color: "#111417"
image: assets/icon.png

74
app/test/cache_test.dart Normal file
View File

@@ -0,0 +1,74 @@
// 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_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:potato_mesh_reader/main.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('NodeShortNameCache fetches and memoizes short names', () async {
var calls = 0;
final client = MockClient((request) async {
calls += 1;
expect(request.url.path, '/api/nodes/!cache-test');
return http.Response('{"short_name":"NODE"}', 200);
});
final first = await NodeShortNameCache.instance.shortNameFor(
domain: 'cache.test',
nodeId: '!cache-test',
client: client,
);
final second = await NodeShortNameCache.instance.shortNameFor(
domain: 'cache.test',
nodeId: '!cache-test',
client: client,
);
expect(first, 'NODE');
expect(second, 'NODE');
expect(calls, 1, reason: 'memoises results per domain/id');
});
test('NodeShortNameCache falls back to padded suffix', () {
expect(NodeShortNameCache.fallbackShortName('!ab'), ' ab');
expect(NodeShortNameCache.fallbackShortName('!abcdef'), 'cdef');
expect(NodeShortNameCache.fallbackShortName(''), '????');
});
test('InstanceVersionCache fetches and caches version payloads', () async {
var calls = 0;
final client = MockClient((request) async {
calls += 1;
expect(request.url.path, '/version');
return http.Response(
'{"name":"BerlinMesh","config":{"channel":"#MediumFast","frequency":"868MHz","instanceDomain":"potatomesh.net"}}',
200,
);
});
final first = await InstanceVersionCache.instance
.fetch(domain: 'version.test', client: client);
final second = await InstanceVersionCache.instance
.fetch(domain: 'version.test', client: client);
expect(first?.summary, contains('BerlinMesh'));
expect(first?.summary, contains('#MediumFast'));
expect(calls, 1, reason: 'cache should avoid duplicate network calls');
expect(second?.summary, first?.summary);
});
}

View File

@@ -159,7 +159,7 @@ void main() {
final messages = await fetchMessages(client: client);
expect(calls.single.queryParameters['limit'], '100');
expect(calls.single.queryParameters['limit'], '1000');
expect(messages.first.id, 1);
expect(messages.last.id, 2);
expect(messages.first.fromShort, 'a');

View File

@@ -50,9 +50,9 @@ void main() {
await tester.pumpWidget(PotatoMeshReaderApp(fetcher: fakeFetch));
await tester.pumpAndSettle();
expect(find.text('Meshtastic Reader'), findsOneWidget);
expect(find.textContaining('PotatoMesh Reader'), findsOneWidget);
expect(find.byType(MessagesScreen), findsOneWidget);
expect(fetchCalls.length, 1);
expect(fetchCalls.length, greaterThanOrEqualTo(2));
});
testWidgets('MessagesScreen shows loading, data, refresh, and empty states',
@@ -64,29 +64,32 @@ void main() {
if (fetchCount == 1) {
return completer.future;
}
if (fetchCount == 2) {
return Future.value([
MeshMessage(
id: 2,
rxTime: DateTime.utc(2024, 1, 1, 10, 0),
rxIso: '2024-01-01T10:00:00Z',
fromId: '!a',
toId: '^',
channel: 1,
channelName: null,
portnum: 'TEXT',
text: '',
rssi: -40,
snr: 1.1,
hopLimit: 1,
),
]);
}
return Future.error(StateError('no new data'));
return Future.value([
MeshMessage(
id: fetchCount,
rxTime: DateTime.utc(2024, 1, 1, 10, fetchCount),
rxIso: '2024-01-01T10:00:00Z',
fromId: '!a',
toId: '^',
channel: 1,
channelName: 'General',
portnum: 'TEXT',
text: 'Message $fetchCount',
rssi: -40,
snr: 1.1,
hopLimit: 1,
),
]);
}
await tester
.pumpWidget(MaterialApp(home: MessagesScreen(fetcher: fetcher)));
await tester.pumpWidget(
MaterialApp(
home: MessagesScreen(
fetcher: fetcher,
domain: 'potatomesh.net',
),
),
);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
@@ -109,25 +112,21 @@ void main() {
await tester.pumpAndSettle();
expect(find.textContaining('Loaded'), findsOneWidget);
expect(find.textContaining('General'), findsOneWidget);
expect(fetchCount, 1);
expect(fetchCount, greaterThanOrEqualTo(2));
await tester.tap(find.byIcon(Icons.refresh));
await tester.pump();
await tester.pumpAndSettle();
expect(fetchCount, 2);
expect(find.text('⟂ (no text)'), findsOneWidget);
await tester.tap(find.byIcon(Icons.refresh));
await tester.pumpAndSettle();
expect(find.textContaining('Failed to load messages'), findsOneWidget);
expect(fetchCount, greaterThanOrEqualTo(3));
expect(find.textContaining('Message'), findsWidgets);
await tester.pumpWidget(
MaterialApp(
home: MessagesScreen(fetcher: () async => []),
home: MessagesScreen(
fetcher: () async => [],
domain: 'potatomesh.net',
),
),
);
await tester.pumpAndSettle();
@@ -140,6 +139,7 @@ void main() {
MaterialApp(
home: MessagesScreen(
fetcher: () async => [],
domain: 'potatomesh.net',
onOpenSettings: (context) {
Navigator.of(context).push(
MaterialPageRoute(
@@ -160,9 +160,12 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Settings'), findsOneWidget);
expect(find.textContaining('Meshtastic Reader'), findsOneWidget);
expect(find.textContaining('PotatoMesh Reader'), findsOneWidget);
});
// Stale fetch completions are ignored by versioned fetch guard; covered
// indirectly by other tests that rely on append ordering.
testWidgets('changing endpoint triggers a refresh with new domain',
(tester) async {
final calls = <String>[];
@@ -201,7 +204,7 @@ void main() {
);
await tester.pumpAndSettle();
expect(calls.single, 'potatomesh.net');
expect(calls.first, 'potatomesh.net');
expect(find.text('potatomesh.net'), findsOneWidget);
await tester.tap(find.byIcon(Icons.settings));
@@ -235,14 +238,19 @@ void main() {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(body: ChatLine(message: message)),
home: Scaffold(
body: ChatLine(
message: message,
domain: 'potatomesh.net',
),
),
),
);
final nickText = find.textContaining('<ColorNick>');
final nickText = find.textContaining('<!ColorNick>');
final placeholder = find.text('⟂ (no text)');
expect(nickText, findsOneWidget);
expect(placeholder, findsOneWidget);
expect(find.text('[--:--]'), findsOneWidget);
expect(find.textContaining('[--:--]'), findsOneWidget);
});
}

View File

@@ -59,17 +59,12 @@ void main() {
await tester.pumpWidget(PotatoMeshReaderApp(fetcher: mockFetcher));
await tester.pumpAndSettle();
expect(find.text('Meshtastic Reader'), findsOneWidget);
expect(find.text('[--:--]'), findsOneWidget);
expect(find.text('<nodeA>'), findsOneWidget);
expect(find.text('hello world'), findsOneWidget);
expect(find.text('#TEST'), findsOneWidget);
await tester.tap(find.byTooltip('Refresh'));
await tester.pumpAndSettle();
expect(find.text('<nodeB>'), findsOneWidget);
expect(find.text('second message'), findsOneWidget);
expect(find.text('<nodeA>'), findsNothing);
expect(find.textContaining('PotatoMesh Reader'), findsOneWidget);
expect(find.textContaining('[--:--]'), findsWidgets);
expect(find.byType(ChatLine), findsOneWidget);
expect(find.textContaining('hello world'), findsNothing);
expect(find.textContaining('#TEST'), findsOneWidget);
expect(find.textContaining('<!nodeB>'), findsOneWidget);
expect(find.textContaining('second message'), findsOneWidget);
});
}