diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000..78949a6 --- /dev/null +++ b/app/README.md @@ -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`. diff --git a/app/assets/icon.png b/app/assets/icon.png new file mode 100644 index 0000000..e1a1b2e Binary files /dev/null and b/app/assets/icon.png differ diff --git a/app/lib/main.dart b/app/lib/main.dart new file mode 100644 index 0000000..fc0943b --- /dev/null +++ b/app/lib/main.dart @@ -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 createState() => _MessagesScreenState(); +} + +class _MessagesScreenState extends State { + late Future> _future; + + @override + void initState() { + super.initState(); + _future = fetchMessages(); + } + + /// Reloads the message feed and waits for completion for pull-to-refresh. + Future _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>( + 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(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 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> 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((m) => MeshMessage.fromJson(m)) + .toList(); + + return sortMessagesByRxTime(msgs); +} + +/// Returns a new list sorted by receive time so older messages render first. +List sortMessagesByRxTime(List 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; +} diff --git a/app/pubspec.yaml b/app/pubspec.yaml new file mode 100644 index 0000000..7d44806 --- /dev/null +++ b/app/pubspec.yaml @@ -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 diff --git a/app/test/mesh_message_test.dart b/app/test/mesh_message_test.dart new file mode 100644 index 0000000..a380419 --- /dev/null +++ b/app/test/mesh_message_test.dart @@ -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); + }); + }); +}