Add Meshtastic reader Flutter app (#483)

* Add Meshtastic reader Flutter app

* fix review comments
This commit is contained in:
l5y
2025-11-21 17:11:18 +01:00
committed by GitHub
parent ca4a55312f
commit 21cecc970f
5 changed files with 503 additions and 0 deletions

15
app/README.md Normal file
View File

@@ -0,0 +1,15 @@
# Meshtastic Reader
Meshtastic Reader read-only PotatoMesh chat client for Android and iOS.
## Setup
```bash
cd app
flutter create .
# then replace pubspec.yaml and lib/main.dart with the versions in this repo
flutter pub get
flutter run
```
The app fetches from `https://potatomesh.net/api/messages?limit=100&encrypted=false`.

BIN
app/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

349
app/lib/main.dart Normal file
View File

@@ -0,0 +1,349 @@
// 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 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
void main() {
runApp(const PotatoMeshReaderApp());
}
/// Meshtastic Reader root widget that configures theming and the home screen.
class PotatoMeshReaderApp extends StatelessWidget {
const PotatoMeshReaderApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Meshtastic Reader',
debugShowCheckedModeBanner: false,
theme: ThemeData(
brightness: Brightness.dark,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.teal,
brightness: Brightness.dark,
),
useMaterial3: true,
textTheme: const TextTheme(
bodyMedium: TextStyle(
fontFamily: 'monospace',
fontSize: 13,
height: 1.15,
),
),
),
home: const MessagesScreen(),
);
}
}
/// Displays the fetched mesh messages and supports pull-to-refresh.
class MessagesScreen extends StatefulWidget {
const MessagesScreen({super.key});
@override
State<MessagesScreen> createState() => _MessagesScreenState();
}
class _MessagesScreenState extends State<MessagesScreen> {
late Future<List<MeshMessage>> _future;
@override
void initState() {
super.initState();
_future = fetchMessages();
}
/// Reloads the message feed and waits for completion for pull-to-refresh.
Future<void> _refresh() async {
setState(() {
_future = fetchMessages();
});
await _future;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('#BerlinMesh'),
actions: [
IconButton(
tooltip: 'Refresh',
icon: const Icon(Icons.refresh),
onPressed: _refresh,
),
IconButton(
tooltip: 'Settings',
icon: const Icon(Icons.settings),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const SettingsScreen(),
),
);
},
),
],
),
body: FutureBuilder<List<MeshMessage>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Failed to load messages:\n${snapshot.error}',
textAlign: TextAlign.center,
),
),
);
}
final messages = snapshot.data ?? [];
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);
},
),
);
},
),
);
}
}
/// Individual chat line styled in IRC-inspired format.
class ChatLine extends StatelessWidget {
const ChatLine({super.key, required this.message});
/// Message data to render.
final MeshMessage message;
/// Generates a stable color from the nickname characters by hashing to a hue.
Color _nickColor(String nick) {
final h = nick.codeUnits.fold<int>(0, (a, b) => (a + b) % 360);
return HSLColor.fromAHSL(1, h.toDouble(), 0.5, 0.6).toColor();
}
@override
Widget build(BuildContext context) {
final timeStr = '[${message.timeFormatted}]';
final nick = '<${message.fromShort}>';
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
child: RichText(
text: TextSpan(
style: DefaultTextStyle.of(context).style,
children: [
TextSpan(
text: '$timeStr ',
style: const TextStyle(
color: Colors.grey,
fontWeight: FontWeight.w500,
),
),
TextSpan(
text: '$nick ',
style: TextStyle(
color: _nickColor(message.fromShort),
fontWeight: FontWeight.w600,
),
),
TextSpan(
text: message.text.isEmpty ? '⟂ (no text)' : message.text,
),
if (message.channelName != null) ...[
const TextSpan(text: ' '),
TextSpan(
text: '#${message.channelName}',
style: const TextStyle(color: Colors.tealAccent),
),
],
],
),
),
);
}
}
/// MVP settings placeholder offering endpoint and about info.
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Settings (MVP)')),
body: ListView(
children: const [
ListTile(
leading: Icon(Icons.cloud),
title: Text('Endpoint'),
subtitle: Text('https://potatomesh.net/api/messages'),
),
Divider(),
ListTile(
leading: Icon(Icons.info_outline),
title: Text('About'),
subtitle: Text('Meshtastic Reader MVP — read-only view of PotatoMesh messages.'),
),
],
),
);
}
}
/// --- Data layer ------------------------------------------------------------
/// Representation of a single mesh message returned by the PotatoMesh API.
class MeshMessage {
final int id;
final DateTime? rxTime;
final String rxIso;
final String fromId;
final String toId;
final int? channel;
final String? channelName;
final String portnum;
final String text;
final int? rssi;
final double? snr;
final int? hopLimit;
/// Creates a [MeshMessage] with all properties parsed from the API response.
MeshMessage({
required this.id,
required this.rxTime,
required this.rxIso,
required this.fromId,
required this.toId,
required this.channel,
required this.channelName,
required this.portnum,
required this.text,
required this.rssi,
required this.snr,
required this.hopLimit,
});
/// Parses a [MeshMessage] from the raw JSON map returned by the API.
factory MeshMessage.fromJson(Map<String, dynamic> json) {
DateTime? parsedTime;
if (json['rx_iso'] is String) {
try {
parsedTime = DateTime.parse(json['rx_iso'] as String).toLocal();
} catch (_) {
parsedTime = null;
}
}
double? parseDouble(dynamic v) {
if (v == null) return null;
if (v is num) return v.toDouble();
return double.tryParse(v.toString());
}
int? parseInt(dynamic v) {
if (v == null) return null;
if (v is int) return v;
return int.tryParse(v.toString());
}
return MeshMessage(
id: parseInt(json['id']) ?? 0,
rxTime: parsedTime,
rxIso: json['rx_iso']?.toString() ?? '',
fromId: json['from_id']?.toString() ?? '',
toId: json['to_id']?.toString() ?? '',
channel: parseInt(json['channel']),
channelName: json['channel_name']?.toString(),
portnum: json['portnum']?.toString() ?? '',
text: json['text']?.toString() ?? '',
rssi: parseInt(json['rssi']),
snr: parseDouble(json['snr']),
hopLimit: parseInt(json['hop_limit']),
);
}
/// Formats the message time as HH:MM in local time.
String get timeFormatted {
if (rxTime == null) return '--:--';
final h = rxTime!.hour.toString().padLeft(2, '0');
final m = rxTime!.minute.toString().padLeft(2, '0');
return '$h:$m';
}
/// Returns sender without a leading `!` prefix for display.
String get fromShort {
if (fromId.isEmpty) return '?';
return fromId.startsWith('!') ? fromId.substring(1) : fromId;
}
}
/// Fetches the latest PotatoMesh messages and returns them sorted by receive time.
Future<List<MeshMessage>> fetchMessages() async {
final uri = Uri.https('potatomesh.net', '/api/messages', {
'limit': '100',
'encrypted': 'false',
});
final resp = await http.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 response shape, expected JSON array');
}
final msgs = decoded
.whereType<Map<String, dynamic>>()
.map((m) => MeshMessage.fromJson(m))
.toList();
return sortMessagesByRxTime(msgs);
}
/// Returns a new list sorted by receive time so older messages render first.
List<MeshMessage> sortMessagesByRxTime(List<MeshMessage> messages) {
messages.sort((a, b) {
final at = a.rxTime;
final bt = b.rxTime;
if (at == null && bt == null) return 0;
if (at == null) return 1;
if (bt == null) return -1;
return at.compareTo(bt);
});
return messages;
}

39
app/pubspec.yaml Normal file
View File

@@ -0,0 +1,39 @@
name: potato_mesh_reader
description: Meshtastic Reader — read-only view for PotatoMesh messages.
publish_to: "none"
version: 0.5.6
environment:
sdk: ">=3.4.0 <4.0.0"
dependencies:
flutter:
sdk: flutter
http: ^1.2.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.2
flutter_launcher_icons: ^0.13.1
flutter_native_splash: ^2.4.1
flutter:
uses-material-design: true
assets:
- assets/
flutter_launcher_icons:
android: true
ios: true
image_path: assets/icon.png
remove_alpha_ios: true
adaptive_icon_background: "#111417"
adaptive_icon_foreground: assets/icon.png
flutter_native_splash:
color: "#111417"
image: assets/icon.png
android_12:
color: "#111417"
image: assets/icon.png

View File

@@ -0,0 +1,100 @@
// 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:potato_mesh_reader/main.dart';
/// Unit tests for [MeshMessage] parsing and the sorting helper.
void main() {
group('MeshMessage.fromJson', () {
test('parses fields and strips leading bang from sender', () {
final msg = MeshMessage.fromJson({
'id': '7',
'rx_iso': '2024-01-02T03:04:00Z',
'from_id': '!NICK',
'to_id': '^',
'channel': '1',
'channel_name': 'BerlinMesh',
'portnum': 'TEXT',
'text': 'Hello world',
'rssi': '-90',
'snr': '5.25',
'hop_limit': '3',
});
expect(msg.id, 7);
expect(msg.rxIso, '2024-01-02T03:04:00Z');
expect(msg.rxTime!.toUtc().hour, 3);
expect(msg.fromShort, 'NICK');
expect(msg.channelName, 'BerlinMesh');
expect(msg.text, 'Hello world');
expect(msg.rssi, -90);
expect(msg.snr, closeTo(5.25, 0.0001));
expect(msg.hopLimit, 3);
});
});
group('sortMessagesByRxTime', () {
test('orders messages oldest to newest even with null timestamps', () {
final older = MeshMessage(
id: 1,
rxTime: DateTime.utc(2023, 12, 31, 23, 59),
rxIso: '2023-12-31T23:59:00Z',
fromId: 'A',
toId: 'B',
channel: 1,
channelName: 'Main',
portnum: 'TEXT',
text: 'Old',
rssi: -50,
snr: 1.0,
hopLimit: 1,
);
final unknownTime = MeshMessage(
id: 2,
rxTime: null,
rxIso: '',
fromId: 'B',
toId: 'A',
channel: 1,
channelName: 'Main',
portnum: 'TEXT',
text: 'Unknown',
rssi: -55,
snr: 1.5,
hopLimit: 1,
);
final newer = MeshMessage(
id: 3,
rxTime: DateTime.utc(2024, 01, 01, 0, 10),
rxIso: '2024-01-01T00:10:00Z',
fromId: 'C',
toId: 'D',
channel: 1,
channelName: 'Main',
portnum: 'TEXT',
text: 'New',
rssi: -60,
snr: 2.0,
hopLimit: 1,
);
final sorted = sortMessagesByRxTime([newer, unknownTime, older]);
expect(sorted.first.id, older.id);
expect(sorted.last.id, newer.id);
expect(sorted[1].id, unknownTime.id);
});
});
}