mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-03-28 17:42:48 +01:00
Add Meshtastic reader Flutter app (#483)
* Add Meshtastic reader Flutter app * fix review comments
This commit is contained in:
15
app/README.md
Normal file
15
app/README.md
Normal 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
BIN
app/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
349
app/lib/main.dart
Normal file
349
app/lib/main.dart
Normal 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
39
app/pubspec.yaml
Normal 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
|
||||
100
app/test/mesh_message_test.dart
Normal file
100
app/test/mesh_message_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user