mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-03-28 17:42:48 +01:00
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:
BIN
app/assets/icon-launcher.png
Normal file
BIN
app/assets/icon-launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
BIN
app/assets/icon-splash.png
Normal file
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 |
352
app/assets/potatomesh-logo.svg
Normal file
352
app/assets/potatomesh-logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 65 KiB |
15
app/debug.sh
Executable file
15
app/debug.sh
Executable 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
|
||||
|
||||
@@ -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
|
||||
|
||||
140
app/pubspec.lock
140
app/pubspec.lock
@@ -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"
|
||||
|
||||
@@ -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
74
app/test/cache_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user