mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-03-28 17:42:48 +01:00
3258 lines
100 KiB
Dart
3258 lines
100 KiB
Dart
// 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:async';
|
|
import 'dart:collection';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:math';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_local_notifications/flutter_local_notifications.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:shared_preferences/shared_preferences.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
import 'package:workmanager/workmanager.dart';
|
|
import 'package:potato_mesh_reader/dart_plugin_registrant.dart'
|
|
as dart_plugin_registrant;
|
|
|
|
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: '');
|
|
const Duration _requestTimeout = Duration(seconds: 5);
|
|
const String _themePreferenceKey = 'mesh.themeMode';
|
|
const String _notificationChannelId = 'mesh.messages';
|
|
const String _notificationChannelName = 'Mesh messages';
|
|
const String _notificationChannelDescription =
|
|
'Alerts when new PotatoMesh messages arrive';
|
|
const String _backgroundTaskName = 'mesh_message_poll';
|
|
const String _backgroundTaskId = 'mesh.message.poll';
|
|
const Duration _backgroundFetchInterval = Duration(minutes: 15);
|
|
|
|
/// Client interface used to deliver notifications when unseen messages arrive.
|
|
abstract class NotificationClient {
|
|
const NotificationClient();
|
|
|
|
/// Performs any platform-specific initialization, such as channel creation.
|
|
Future<void> initialize();
|
|
|
|
/// Shows a notification for an unseen message.
|
|
Future<void> showNewMessage({
|
|
required MeshMessage message,
|
|
required String domain,
|
|
String? senderShortName,
|
|
});
|
|
}
|
|
|
|
/// No-op notification client used in tests and web builds.
|
|
class NoopNotificationClient implements NotificationClient {
|
|
const NoopNotificationClient();
|
|
|
|
@override
|
|
Future<void> initialize() async {}
|
|
|
|
@override
|
|
Future<void> showNewMessage({
|
|
required MeshMessage message,
|
|
required String domain,
|
|
String? senderShortName,
|
|
}) async {}
|
|
}
|
|
|
|
/// Platform-aware notification client backed by the Flutter Local Notifications plugin.
|
|
class LocalNotificationClient implements NotificationClient {
|
|
LocalNotificationClient({FlutterLocalNotificationsPlugin? plugin})
|
|
: _plugin = plugin ?? FlutterLocalNotificationsPlugin();
|
|
|
|
final FlutterLocalNotificationsPlugin _plugin;
|
|
bool _initialized = false;
|
|
|
|
AndroidNotificationChannel get _channel => const AndroidNotificationChannel(
|
|
_notificationChannelId,
|
|
_notificationChannelName,
|
|
description: _notificationChannelDescription,
|
|
importance: Importance.high,
|
|
);
|
|
|
|
@override
|
|
Future<void> initialize() async {
|
|
if (_initialized) return;
|
|
if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) {
|
|
// Unit tests and desktop builds do not have a notification host.
|
|
_initialized = true;
|
|
return;
|
|
}
|
|
const androidInit = AndroidInitializationSettings('@mipmap/ic_launcher');
|
|
const iosInit = DarwinInitializationSettings(
|
|
requestAlertPermission: true,
|
|
requestBadgePermission: true,
|
|
requestSoundPermission: true,
|
|
);
|
|
final settings =
|
|
const InitializationSettings(android: androidInit, iOS: iosInit);
|
|
await _plugin.initialize(settings);
|
|
|
|
final android = _plugin.resolvePlatformSpecificImplementation<
|
|
AndroidFlutterLocalNotificationsPlugin>();
|
|
await android?.createNotificationChannel(_channel);
|
|
if (Platform.isAndroid) {
|
|
final enabled = await android?.areNotificationsEnabled() ?? true;
|
|
if (!enabled) {
|
|
final granted =
|
|
await android?.requestNotificationsPermission() ?? false;
|
|
debugPrint('D/Notifications: permission requested; granted=$granted');
|
|
}
|
|
}
|
|
debugPrint('D/Notifications: initialized');
|
|
|
|
_initialized = true;
|
|
}
|
|
|
|
NotificationDetails _notificationDetails() {
|
|
const androidDetails = AndroidNotificationDetails(
|
|
_notificationChannelId,
|
|
_notificationChannelName,
|
|
channelDescription: _notificationChannelDescription,
|
|
importance: Importance.high,
|
|
priority: Priority.high,
|
|
enableVibration: true,
|
|
playSound: true,
|
|
);
|
|
const iosDetails = DarwinNotificationDetails(
|
|
presentAlert: true,
|
|
presentBadge: true,
|
|
presentSound: true,
|
|
);
|
|
return const NotificationDetails(
|
|
android: androidDetails,
|
|
iOS: iosDetails,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> showNewMessage({
|
|
required MeshMessage message,
|
|
required String domain,
|
|
String? senderShortName,
|
|
}) async {
|
|
await initialize();
|
|
if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) return;
|
|
debugPrint('D/Notifications: showing message ${message.id} on $domain');
|
|
final displaySender = senderShortName?.trim().isNotEmpty == true
|
|
? senderShortName!.trim()
|
|
: message.fromShort;
|
|
final channel = message.channelName?.trim().isNotEmpty == true
|
|
? message.channelName!.trim()
|
|
: domain;
|
|
final title = 'New message from $displaySender';
|
|
final body = message.text.trim().isNotEmpty
|
|
? message.text.trim()
|
|
: 'New message on $channel';
|
|
await _plugin.show(
|
|
message.id.hashCode.abs(),
|
|
title,
|
|
body,
|
|
_notificationDetails(),
|
|
payload: domain,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Callback dispatcher used by the Workmanager plugin.
|
|
@pragma('vm:entry-point')
|
|
void _workmanagerCallbackDispatcher() {
|
|
Workmanager().executeTask((task, inputData) async {
|
|
try {
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
dart_plugin_registrant.DartPluginRegistrant.ensureInitialized();
|
|
return await BackgroundSyncManager.handleBackgroundTask(
|
|
task,
|
|
inputData,
|
|
);
|
|
} catch (error, stackTrace) {
|
|
debugPrint('E/BackgroundSync dispatcher failed: $error\n$stackTrace');
|
|
return false;
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Workmanager abstraction to simplify testing.
|
|
abstract class WorkmanagerAdapter {
|
|
const WorkmanagerAdapter();
|
|
|
|
Future<void> initialize(Function dispatcher);
|
|
|
|
Future<void> registerPeriodicTask(
|
|
String taskId,
|
|
String taskName, {
|
|
Duration frequency,
|
|
ExistingPeriodicWorkPolicy existingWorkPolicy,
|
|
Duration? initialDelay,
|
|
Constraints? constraints,
|
|
});
|
|
|
|
Future<void> cancelAll();
|
|
}
|
|
|
|
/// Real Workmanager adapter used in production builds.
|
|
class FlutterWorkmanagerAdapter implements WorkmanagerAdapter {
|
|
FlutterWorkmanagerAdapter({Workmanager? delegate})
|
|
: _delegate = delegate ?? Workmanager();
|
|
|
|
final Workmanager _delegate;
|
|
|
|
@override
|
|
Future<void> initialize(Function dispatcher) {
|
|
return _delegate.initialize(dispatcher);
|
|
}
|
|
|
|
@override
|
|
Future<void> registerPeriodicTask(
|
|
String taskId,
|
|
String taskName, {
|
|
Duration frequency = _backgroundFetchInterval,
|
|
ExistingPeriodicWorkPolicy existingWorkPolicy =
|
|
ExistingPeriodicWorkPolicy.keep,
|
|
Duration? initialDelay,
|
|
Constraints? constraints,
|
|
}) {
|
|
return _delegate.registerPeriodicTask(
|
|
taskId,
|
|
taskName,
|
|
frequency: frequency,
|
|
existingWorkPolicy: existingWorkPolicy,
|
|
initialDelay: initialDelay,
|
|
constraints: constraints,
|
|
inputData: const {},
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> cancelAll() {
|
|
return _delegate.cancelAll();
|
|
}
|
|
}
|
|
|
|
/// Factories used to build repositories and notifications for background work.
|
|
class BackgroundDependencies {
|
|
const BackgroundDependencies({
|
|
required this.repositoryBuilder,
|
|
required this.notificationBuilder,
|
|
});
|
|
|
|
final Future<MeshRepository> Function() repositoryBuilder;
|
|
final Future<NotificationClient> Function() notificationBuilder;
|
|
}
|
|
|
|
/// Schedules and executes periodic background message refreshes.
|
|
class BackgroundSyncManager {
|
|
BackgroundSyncManager({
|
|
required this.workmanager,
|
|
required this.dependencies,
|
|
});
|
|
|
|
final WorkmanagerAdapter workmanager;
|
|
final BackgroundDependencies dependencies;
|
|
|
|
static final BackgroundDependencies _defaultDependencies =
|
|
BackgroundDependencies(
|
|
repositoryBuilder: () async => MeshRepository(),
|
|
notificationBuilder: () async => LocalNotificationClient(),
|
|
);
|
|
static BackgroundDependencies? _registeredDependencies;
|
|
|
|
/// Registers dependencies for the Workmanager callback and schedules polling.
|
|
Future<void> initialize({bool debugMode = false}) async {
|
|
_registeredDependencies = dependencies;
|
|
await workmanager.initialize(_workmanagerCallbackDispatcher);
|
|
}
|
|
|
|
/// Schedules the periodic background fetch task.
|
|
Future<void> ensurePeriodicTask() {
|
|
return workmanager.registerPeriodicTask(
|
|
_backgroundTaskId,
|
|
_backgroundTaskName,
|
|
frequency: _backgroundFetchInterval,
|
|
existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
|
|
initialDelay: const Duration(minutes: 1),
|
|
constraints: Constraints(
|
|
networkType: NetworkType.connected,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Clears registered dependencies; intended for tests only.
|
|
@visibleForTesting
|
|
static void resetForTest() {
|
|
_registeredDependencies = null;
|
|
}
|
|
|
|
/// Executes the background task to fetch messages and post notifications.
|
|
@pragma('vm:entry-point')
|
|
static Future<bool> handleBackgroundTask(
|
|
String task,
|
|
Map<String, dynamic>? inputData,
|
|
) async {
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
final deps = _registeredDependencies ??
|
|
((Platform.isAndroid || Platform.isIOS) ? _defaultDependencies : null);
|
|
if (deps == null) {
|
|
debugPrint('D/BackgroundSync: no dependencies registered; skipping');
|
|
return true;
|
|
}
|
|
try {
|
|
debugPrint('D/BackgroundSync: start task=$task');
|
|
final repository = await deps.repositoryBuilder();
|
|
final notification = await deps.notificationBuilder();
|
|
await notification.initialize();
|
|
|
|
final domain = await repository.loadSelectedDomainOrDefault();
|
|
final messages = await repository.loadMessages(domain: domain);
|
|
final unseen = await repository.detectUnseenMessages(
|
|
domain: domain,
|
|
messages: messages,
|
|
);
|
|
|
|
debugPrint(
|
|
'D/BackgroundSync: task=$task domain=$domain fetched=${messages.length} unseen=${unseen.length}');
|
|
|
|
for (final message in unseen) {
|
|
final sender = NodeShortNameCache.fallbackShortName(
|
|
message.lookupNodeId.isNotEmpty
|
|
? message.lookupNodeId
|
|
: message.fromId,
|
|
);
|
|
debugPrint(
|
|
'D/BackgroundSync: notifying message=${message.id} sender=$sender');
|
|
await notification.showNewMessage(
|
|
message: message,
|
|
domain: domain,
|
|
senderShortName: sender,
|
|
);
|
|
}
|
|
return true;
|
|
} on SocketException catch (error) {
|
|
debugPrint('W/BackgroundSync: network unavailable ($error); will retry');
|
|
return true;
|
|
} catch (error, stackTrace) {
|
|
debugPrint('E/BackgroundSync failed: $error\n$stackTrace');
|
|
// Return true to avoid aggressive retries if the environment blocks plugins.
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
void _logHttp(String message) {
|
|
debugPrint('D/$message');
|
|
}
|
|
|
|
Future<List<Map<String, dynamic>>> _decodeJsonList(String body) {
|
|
return compute(_decodeJsonListSync, body);
|
|
}
|
|
|
|
List<Map<String, dynamic>> _decodeJsonListSync(String body) {
|
|
final dynamic decoded = jsonDecode(body);
|
|
if (decoded is! List) {
|
|
throw const FormatException('Expected JSON array');
|
|
}
|
|
return decoded.whereType<Map<String, dynamic>>().toList();
|
|
}
|
|
|
|
Future<Map<String, dynamic>> _decodeJsonMap(String body) {
|
|
return compute(_decodeJsonMapSync, body);
|
|
}
|
|
|
|
Map<String, dynamic> _decodeJsonMapSync(String body) {
|
|
final dynamic decoded = jsonDecode(body);
|
|
if (decoded is! Map<String, dynamic>) {
|
|
throw const FormatException('Expected JSON object');
|
|
}
|
|
return decoded;
|
|
}
|
|
|
|
Future<void> main() async {
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
final notificationClient = LocalNotificationClient();
|
|
await notificationClient.initialize();
|
|
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
|
|
final backgroundManager = BackgroundSyncManager(
|
|
workmanager: FlutterWorkmanagerAdapter(),
|
|
dependencies: BackgroundDependencies(
|
|
repositoryBuilder: () async => MeshRepository(),
|
|
notificationBuilder: () async => LocalNotificationClient(),
|
|
),
|
|
);
|
|
await backgroundManager.initialize();
|
|
await backgroundManager.ensurePeriodicTask();
|
|
}
|
|
runApp(PotatoMeshReaderApp(
|
|
notificationClient: notificationClient,
|
|
));
|
|
}
|
|
|
|
/// Persistent storage for the theme choice so the UI can honor user intent.
|
|
class ThemePreferenceStore {
|
|
const ThemePreferenceStore();
|
|
|
|
Future<ThemeMode> load() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final stored = prefs.getString(_themePreferenceKey);
|
|
switch (stored) {
|
|
case 'dark':
|
|
return ThemeMode.dark;
|
|
case 'light':
|
|
return ThemeMode.light;
|
|
case 'system':
|
|
return ThemeMode.system;
|
|
default:
|
|
return ThemeMode.system;
|
|
}
|
|
}
|
|
|
|
Future<void> save(ThemeMode mode) async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final value = mode == ThemeMode.dark
|
|
? 'dark'
|
|
: mode == ThemeMode.light
|
|
? 'light'
|
|
: 'system';
|
|
await prefs.setString(_themePreferenceKey, value);
|
|
}
|
|
}
|
|
|
|
/// Function type used to fetch messages from a specific endpoint.
|
|
typedef MessageFetcher = Future<List<MeshMessage>> Function({
|
|
http.Client? client,
|
|
String domain,
|
|
});
|
|
|
|
/// PotatoMesh Reader root widget that configures theming and the home screen.
|
|
class PotatoMeshReaderApp extends StatefulWidget {
|
|
const PotatoMeshReaderApp({
|
|
super.key,
|
|
this.fetcher = fetchMessages,
|
|
this.instanceFetcher = fetchInstances,
|
|
this.initialDomain = 'potatomesh.net',
|
|
this.repository,
|
|
this.bootstrapper,
|
|
this.enableAutoRefresh = true,
|
|
this.themeStore = const ThemePreferenceStore(),
|
|
this.notificationClient = const NoopNotificationClient(),
|
|
});
|
|
|
|
/// Fetch function injected to simplify testing and offline previews.
|
|
final MessageFetcher fetcher;
|
|
|
|
/// Loader for federation instance metadata, overridable in tests.
|
|
final Future<List<MeshInstance>> Function({http.Client? client})
|
|
instanceFetcher;
|
|
|
|
/// Initial endpoint domain used when the app boots.
|
|
final String initialDomain;
|
|
|
|
/// Backing repository controlling persistence and caching.
|
|
final MeshRepository? repository;
|
|
|
|
/// Optional bootstrapper override for testing or previews.
|
|
final Future<BootstrapResult> Function({ProgressCallback? onProgress})?
|
|
bootstrapper;
|
|
|
|
/// Whether the chat view should periodically refresh messages.
|
|
final bool enableAutoRefresh;
|
|
|
|
/// Storage used to persist the chosen theme.
|
|
final ThemePreferenceStore themeStore;
|
|
|
|
/// Client responsible for platform notifications.
|
|
final NotificationClient notificationClient;
|
|
|
|
@override
|
|
State<PotatoMeshReaderApp> createState() => _PotatoMeshReaderAppState();
|
|
}
|
|
|
|
class _PotatoMeshReaderAppState extends State<PotatoMeshReaderApp> {
|
|
late String _endpointDomain;
|
|
int _endpointVersion = 0;
|
|
late final MeshRepository _repository;
|
|
late final NotificationClient _notificationClient;
|
|
final GlobalKey<ScaffoldMessengerState> _messengerKey =
|
|
GlobalKey<ScaffoldMessengerState>();
|
|
BootstrapProgress _progress =
|
|
const BootstrapProgress(stage: 'loading instances');
|
|
Future<BootstrapResult>? _bootstrapFuture;
|
|
BootstrapResult? _bootstrapResult;
|
|
Object? _lastError;
|
|
ThemeMode _themeMode = ThemeMode.system;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_endpointDomain = widget.initialDomain;
|
|
_repository = widget.repository ?? MeshRepository();
|
|
_notificationClient = widget.notificationClient;
|
|
NodeShortNameCache.instance.registerResolver(_repository);
|
|
_loadThemeMode();
|
|
_startBootstrap();
|
|
}
|
|
|
|
Future<void> _loadThemeMode() async {
|
|
final mode = await widget.themeStore.load();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_themeMode = mode;
|
|
});
|
|
}
|
|
|
|
void _startBootstrap() {
|
|
final loader = widget.bootstrapper ??
|
|
(({ProgressCallback? onProgress}) => _repository.bootstrap(
|
|
initialDomain: widget.initialDomain,
|
|
onProgress: onProgress,
|
|
));
|
|
|
|
setState(() {
|
|
_bootstrapFuture = loader(onProgress: _updateProgress);
|
|
});
|
|
|
|
_bootstrapFuture!.then((result) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_bootstrapResult = result;
|
|
_endpointDomain = result.selectedDomain;
|
|
_endpointVersion += 1;
|
|
_lastError = null;
|
|
});
|
|
}).catchError((error) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_lastError = error;
|
|
});
|
|
});
|
|
}
|
|
|
|
void _updateProgress(BootstrapProgress progress) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_progress = progress;
|
|
});
|
|
}
|
|
|
|
String _normalizeDomain(String domain) {
|
|
var cleaned = domain.trim().toLowerCase();
|
|
if (cleaned.startsWith('https://')) cleaned = cleaned.substring(8);
|
|
if (cleaned.startsWith('http://')) cleaned = cleaned.substring(7);
|
|
if (cleaned.endsWith('/')) {
|
|
cleaned = cleaned.substring(0, cleaned.length - 1);
|
|
}
|
|
return cleaned;
|
|
}
|
|
|
|
String? _instanceNameFor(String domain) {
|
|
final normalized = _normalizeDomain(domain);
|
|
final candidates = <MeshInstance>[
|
|
..._repository.instances,
|
|
if (_bootstrapResult != null) ..._bootstrapResult!.instances,
|
|
];
|
|
for (final instance in candidates) {
|
|
if (_normalizeDomain(instance.domain) == normalized) {
|
|
return instance.displayName;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Future<List<MeshInstance>> _loadInstances({bool refresh = false}) async {
|
|
if (!refresh && _repository.instances.isNotEmpty) {
|
|
return _repository.instances;
|
|
}
|
|
final instances = await widget.instanceFetcher();
|
|
await _repository.updateInstances(instances);
|
|
return instances;
|
|
}
|
|
|
|
Future<void> _handleThemeChanged(ThemeMode mode) async {
|
|
setState(() {
|
|
_themeMode = mode;
|
|
});
|
|
await widget.themeStore.save(mode);
|
|
}
|
|
|
|
Future<void> _handleEndpointChanged(String newDomain) async {
|
|
if (newDomain.isEmpty || newDomain == _endpointDomain) {
|
|
return;
|
|
}
|
|
|
|
final previousDomain = _endpointDomain;
|
|
final previousSelectedDomain = _repository.selectedDomain;
|
|
await _repository.rememberSelectedDomain(newDomain);
|
|
final future = _repository
|
|
.loadDomainData(
|
|
domain: newDomain,
|
|
forceFull: true,
|
|
onProgress: _updateProgress,
|
|
)
|
|
.then(
|
|
(domainResult) => BootstrapResult(
|
|
instances: _repository.instances,
|
|
nodes: domainResult.nodes,
|
|
messages: domainResult.messages,
|
|
selectedDomain: domainResult.domain,
|
|
),
|
|
);
|
|
|
|
setState(() {
|
|
_bootstrapFuture = future;
|
|
_endpointDomain = newDomain;
|
|
_endpointVersion += 1;
|
|
_lastError = null;
|
|
});
|
|
|
|
try {
|
|
final result = await future;
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_bootstrapResult = result;
|
|
_endpointDomain = result.selectedDomain;
|
|
_lastError = null;
|
|
});
|
|
} catch (error) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_lastError = error;
|
|
_endpointDomain = previousDomain;
|
|
});
|
|
await _repository.rememberSelectedDomain(previousSelectedDomain);
|
|
_messengerKey.currentState?.showSnackBar(
|
|
SnackBar(content: Text('Failed to switch instance: $error')),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<List<MeshMessage>> _fetchMessagesForCurrentDomain({
|
|
http.Client? client,
|
|
String domain = '',
|
|
}) {
|
|
final activeDomain = domain.isNotEmpty ? domain : _endpointDomain;
|
|
final hasCustomFetcher = !identical(widget.fetcher, fetchMessages);
|
|
if (hasCustomFetcher) {
|
|
return widget.fetcher(domain: activeDomain, client: client);
|
|
}
|
|
return _repository.loadMessages(domain: activeDomain);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final seed = Colors.teal;
|
|
final lightTheme = ThemeData(
|
|
brightness: Brightness.light,
|
|
colorScheme: ColorScheme.fromSeed(
|
|
seedColor: seed,
|
|
brightness: Brightness.light,
|
|
),
|
|
useMaterial3: true,
|
|
textTheme: const TextTheme(
|
|
bodyMedium: TextStyle(
|
|
fontFamily: 'monospace',
|
|
fontSize: 13,
|
|
height: 1.15,
|
|
),
|
|
),
|
|
);
|
|
final darkTheme = ThemeData(
|
|
brightness: Brightness.dark,
|
|
colorScheme: ColorScheme.fromSeed(
|
|
seedColor: seed,
|
|
brightness: Brightness.dark,
|
|
),
|
|
useMaterial3: true,
|
|
textTheme: const TextTheme(
|
|
bodyMedium: TextStyle(
|
|
fontFamily: 'monospace',
|
|
fontSize: 13,
|
|
height: 1.15,
|
|
),
|
|
),
|
|
);
|
|
|
|
return MaterialApp(
|
|
title: '🥔 PotatoMesh Reader',
|
|
debugShowCheckedModeBanner: false,
|
|
scaffoldMessengerKey: _messengerKey,
|
|
theme: lightTheme,
|
|
darkTheme: darkTheme,
|
|
themeMode: _themeMode,
|
|
home: FutureBuilder<BootstrapResult>(
|
|
future: _bootstrapFuture,
|
|
builder: (context, snapshot) {
|
|
final effectiveResult = snapshot.data ?? _bootstrapResult;
|
|
if (effectiveResult == null) {
|
|
return LoadingScreen(
|
|
progress: _progress,
|
|
error: _lastError ?? snapshot.error,
|
|
);
|
|
}
|
|
|
|
final domain = _repository.selectedDomain.isNotEmpty
|
|
? _repository.selectedDomain
|
|
: effectiveResult.selectedDomain;
|
|
final instanceName = _instanceNameFor(domain);
|
|
final initialMessages = (effectiveResult.selectedDomain == domain)
|
|
? effectiveResult.messages
|
|
: const <MeshMessage>[];
|
|
return MessagesScreen(
|
|
key: ValueKey<String>(domain),
|
|
fetcher: _fetchMessagesForCurrentDomain,
|
|
resetToken: _endpointVersion,
|
|
domain: domain,
|
|
repository: _repository,
|
|
notificationClient: _notificationClient,
|
|
instanceName: instanceName,
|
|
enableAutoRefresh: widget.enableAutoRefresh,
|
|
initialMessages: initialMessages,
|
|
onOpenSettings: (context) {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => SettingsScreen(
|
|
currentDomain: _repository.selectedDomain.isNotEmpty
|
|
? _repository.selectedDomain
|
|
: domain,
|
|
onDomainChanged: _handleEndpointChanged,
|
|
loadInstances: ({bool refresh = false}) =>
|
|
_loadInstances(refresh: refresh),
|
|
themeMode: _themeMode,
|
|
onThemeChanged: _handleThemeChanged,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Splash-style loading view shown while federation data is hydrated.
|
|
class LoadingScreen extends StatelessWidget {
|
|
const LoadingScreen({
|
|
super.key,
|
|
required this.progress,
|
|
this.error,
|
|
});
|
|
|
|
final BootstrapProgress progress;
|
|
final Object? error;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final label = error != null
|
|
? 'Failed to load: $error'
|
|
: (progress.label.isNotEmpty ? progress.label : 'Loading…');
|
|
return Scaffold(
|
|
body: Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 24),
|
|
child: Image.asset(
|
|
'assets/icon-splash.png',
|
|
height: 120,
|
|
semanticLabel: 'PotatoMesh',
|
|
),
|
|
),
|
|
const CircularProgressIndicator(),
|
|
const SizedBox(height: 28),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
child: Text(
|
|
label,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Progress payload used to render bootstrap and domain load status.
|
|
class BootstrapProgress {
|
|
const BootstrapProgress({
|
|
required this.stage,
|
|
this.current,
|
|
this.total,
|
|
this.detail,
|
|
});
|
|
|
|
final String stage;
|
|
final int? current;
|
|
final int? total;
|
|
final String? detail;
|
|
|
|
/// Human-friendly label summarising the current progress state.
|
|
String get label {
|
|
final buffer = StringBuffer(stage);
|
|
if (current != null && total != null && total! > 0) {
|
|
buffer.write(' ${current!}/${total!}');
|
|
}
|
|
if (detail != null && detail!.isNotEmpty) {
|
|
buffer.write(' • ${detail!}');
|
|
}
|
|
return buffer.toString();
|
|
}
|
|
}
|
|
|
|
/// Result container returned once federation, nodes, and messages are loaded.
|
|
class BootstrapResult {
|
|
const BootstrapResult({
|
|
required this.instances,
|
|
required this.nodes,
|
|
required this.messages,
|
|
required this.selectedDomain,
|
|
});
|
|
|
|
final List<MeshInstance> instances;
|
|
final List<MeshNode> nodes;
|
|
final List<MeshMessage> messages;
|
|
final String selectedDomain;
|
|
}
|
|
|
|
/// Domain-level fetch outcome used when switching between instances.
|
|
class DomainLoadResult {
|
|
const DomainLoadResult({
|
|
required this.domain,
|
|
required this.nodes,
|
|
required this.messages,
|
|
});
|
|
|
|
final String domain;
|
|
final List<MeshNode> nodes;
|
|
final List<MeshMessage> messages;
|
|
}
|
|
|
|
typedef ProgressCallback = void Function(BootstrapProgress progress);
|
|
|
|
/// Thin wrapper around [SharedPreferences] used to persist federation data.
|
|
class MeshLocalStore {
|
|
MeshLocalStore(this._prefs);
|
|
|
|
final SharedPreferences _prefs;
|
|
|
|
static const String _instancesKey = 'mesh.instances';
|
|
static const String _selectedDomainKey = 'mesh.selectedDomain';
|
|
static const String _lastSeenKey = 'mesh.lastSeen';
|
|
|
|
String _safeKey(String domain) {
|
|
final base = domain.trim().isEmpty ? 'potatomesh.net' : domain.trim();
|
|
return base.replaceAll(RegExp(r'[^a-zA-Z0-9._-]'), '_');
|
|
}
|
|
|
|
Future<void> saveInstances(List<MeshInstance> instances) async {
|
|
final encoded = jsonEncode(instances.map((i) => i.toJson()).toList());
|
|
await _prefs.setString(_instancesKey, encoded);
|
|
}
|
|
|
|
List<MeshInstance> loadInstances() {
|
|
final raw = _prefs.getString(_instancesKey);
|
|
if (raw == null || raw.isEmpty) return const [];
|
|
try {
|
|
final dynamic decoded = jsonDecode(raw);
|
|
if (decoded is! List) return const [];
|
|
return decoded
|
|
.whereType<Map<String, dynamic>>()
|
|
.map(MeshInstance.fromJson)
|
|
.where((instance) => instance.domain.isNotEmpty)
|
|
.toList();
|
|
} catch (_) {
|
|
return const [];
|
|
}
|
|
}
|
|
|
|
Future<void> saveSelectedDomain(String domain) async {
|
|
await _prefs.setString(_selectedDomainKey, domain.trim());
|
|
}
|
|
|
|
String? loadSelectedDomain() {
|
|
return _prefs.getString(_selectedDomainKey);
|
|
}
|
|
|
|
Future<void> saveNodes(String domain, List<MeshNode> nodes) async {
|
|
final encoded = jsonEncode(nodes.map((n) => n.toJson()).toList());
|
|
await _prefs.setString('mesh.nodes.${_safeKey(domain)}', encoded);
|
|
}
|
|
|
|
List<MeshNode> loadNodes(String domain) {
|
|
final raw = _prefs.getString('mesh.nodes.${_safeKey(domain)}');
|
|
if (raw == null || raw.isEmpty) return const [];
|
|
try {
|
|
final dynamic decoded = jsonDecode(raw);
|
|
if (decoded is! List) return const [];
|
|
return decoded
|
|
.whereType<Map<String, dynamic>>()
|
|
.map(MeshNode.fromJson)
|
|
.where((node) => node.nodeId.isNotEmpty)
|
|
.toList();
|
|
} catch (_) {
|
|
return const [];
|
|
}
|
|
}
|
|
|
|
Future<void> saveMessages(String domain, List<MeshMessage> messages) async {
|
|
final trimmed = messages.length > 1200
|
|
? messages.sublist(messages.length - 1200)
|
|
: messages;
|
|
final encoded = jsonEncode(trimmed.map((m) => m.toJson()).toList());
|
|
await _prefs.setString('mesh.messages.${_safeKey(domain)}', encoded);
|
|
}
|
|
|
|
List<MeshMessage> loadMessages(String domain) {
|
|
final raw = _prefs.getString('mesh.messages.${_safeKey(domain)}');
|
|
if (raw == null || raw.isEmpty) return const [];
|
|
try {
|
|
final dynamic decoded = jsonDecode(raw);
|
|
if (decoded is! List) return const [];
|
|
return decoded
|
|
.whereType<Map<String, dynamic>>()
|
|
.map(MeshMessage.fromJson)
|
|
.toList();
|
|
} catch (_) {
|
|
return const [];
|
|
}
|
|
}
|
|
|
|
Future<void> saveLastSeenMessageKey(String domain, String key) async {
|
|
await _prefs.setString('$_lastSeenKey.${_safeKey(domain)}', key);
|
|
}
|
|
|
|
String? loadLastSeenMessageKey(String domain) {
|
|
return _prefs.getString('$_lastSeenKey.${_safeKey(domain)}');
|
|
}
|
|
}
|
|
|
|
/// Provider used by [NodeShortNameCache] to resolve cached node metadata.
|
|
abstract class MeshNodeResolver {
|
|
MeshNode? findNode(String domain, String nodeId);
|
|
}
|
|
|
|
/// Repository responsible for federation discovery, caching, and persistence.
|
|
class MeshRepository implements MeshNodeResolver {
|
|
MeshRepository({
|
|
SharedPreferences? prefs,
|
|
http.Client? client,
|
|
Random? random,
|
|
}) : _prefs = prefs,
|
|
_client = client,
|
|
_random = random ?? Random();
|
|
|
|
SharedPreferences? _prefs;
|
|
MeshLocalStore? _store;
|
|
MessageSeenTracker? _tracker;
|
|
final http.Client? _client;
|
|
final Random _random;
|
|
|
|
final Map<String, List<MeshNode>> _nodesByDomain = {};
|
|
final Map<String, List<MeshMessage>> _messagesByDomain = {};
|
|
final Map<String, bool> _messagesLoaded = {};
|
|
final Map<String, Set<String>> _nodeFetchInFlight = {};
|
|
List<MeshInstance> _instances = const [];
|
|
String _selectedDomain = 'potatomesh.net';
|
|
|
|
List<MeshInstance> get instances => _instances;
|
|
String get selectedDomain => _selectedDomain;
|
|
|
|
Future<MeshLocalStore> _ensureStore() async {
|
|
if (_store != null) return _store!;
|
|
_prefs ??= await SharedPreferences.getInstance();
|
|
_store = MeshLocalStore(_prefs!);
|
|
return _store!;
|
|
}
|
|
|
|
/// Returns the last selected domain, defaulting to the provided fallback.
|
|
Future<String> loadSelectedDomainOrDefault(
|
|
{String fallback = 'potatomesh.net'}) async {
|
|
final store = await _ensureStore();
|
|
final cached = store.loadSelectedDomain();
|
|
_selectedDomain = (cached != null && cached.isNotEmpty) ? cached : fallback;
|
|
return _selectedDomain;
|
|
}
|
|
|
|
Future<MessageSeenTracker> _ensureTracker() async {
|
|
if (_tracker != null) return _tracker!;
|
|
final store = await _ensureStore();
|
|
_tracker = MessageSeenTracker(store);
|
|
return _tracker!;
|
|
}
|
|
|
|
/// Persist the selected domain choice without performing network calls.
|
|
Future<void> rememberSelectedDomain(String domain) async {
|
|
_selectedDomain = _domainKey(domain);
|
|
final store = await _ensureStore();
|
|
await store.saveSelectedDomain(_selectedDomain);
|
|
}
|
|
|
|
String _domainKey(String domain) {
|
|
var cleaned = domain.trim();
|
|
if (cleaned.isEmpty) return 'potatomesh.net';
|
|
cleaned = cleaned.toLowerCase();
|
|
if (cleaned.startsWith('https://')) cleaned = cleaned.substring(8);
|
|
if (cleaned.startsWith('http://')) cleaned = cleaned.substring(7);
|
|
if (cleaned.endsWith('/')) {
|
|
cleaned = cleaned.substring(0, cleaned.length - 1);
|
|
}
|
|
if (cleaned.isEmpty) return 'potatomesh.net';
|
|
return cleaned;
|
|
}
|
|
|
|
/// Kicks off the full bootstrap flow including federation discovery, node
|
|
/// validation, and initial message downloads.
|
|
Future<BootstrapResult> bootstrap({
|
|
String initialDomain = 'potatomesh.net',
|
|
ProgressCallback? onProgress,
|
|
}) async {
|
|
final store = await _ensureStore();
|
|
final cachedInstances = store.loadInstances();
|
|
final hasCachedInstances = cachedInstances.isNotEmpty;
|
|
if (hasCachedInstances) {
|
|
_instances = cachedInstances;
|
|
}
|
|
|
|
final cachedDomain = store.loadSelectedDomain();
|
|
_selectedDomain = (cachedDomain != null && cachedDomain.isNotEmpty)
|
|
? cachedDomain
|
|
: initialDomain;
|
|
|
|
final httpClient = _client ?? http.Client();
|
|
final shouldCloseClient = _client == null;
|
|
|
|
if (!hasCachedInstances) {
|
|
final discovered = await _discoverInstances(
|
|
client: httpClient,
|
|
onProgress: onProgress,
|
|
);
|
|
final validated = await _validateInstances(
|
|
discovered,
|
|
httpClient,
|
|
onProgress,
|
|
);
|
|
if (validated.isNotEmpty) {
|
|
_instances = validated;
|
|
await store.saveInstances(validated);
|
|
}
|
|
}
|
|
|
|
_selectedDomain = _resolveSelectedDomain(_instances, _selectedDomain);
|
|
await store.saveSelectedDomain(_selectedDomain);
|
|
|
|
// Hydrate caches from storage before hitting the network so the UI has
|
|
// something to render if connectivity is constrained.
|
|
final cachedNodes = store.loadNodes(_selectedDomain);
|
|
if (cachedNodes.isNotEmpty) {
|
|
final key = _domainKey(_selectedDomain);
|
|
_nodesByDomain[key] = cachedNodes;
|
|
NodeShortNameCache.instance
|
|
.prime(domain: _selectedDomain, nodes: cachedNodes);
|
|
}
|
|
final cachedMessages = store.loadMessages(_selectedDomain);
|
|
if (cachedMessages.isNotEmpty) {
|
|
_messagesByDomain[_domainKey(_selectedDomain)] = cachedMessages;
|
|
_messagesLoaded[_domainKey(_selectedDomain)] = true;
|
|
}
|
|
|
|
final domainResult = await _loadFirstResponsiveInstance(
|
|
preferredDomain: _selectedDomain,
|
|
candidates: _instances,
|
|
httpClient: httpClient,
|
|
onProgress: onProgress,
|
|
);
|
|
|
|
if (shouldCloseClient) {
|
|
httpClient.close();
|
|
}
|
|
|
|
return BootstrapResult(
|
|
instances: _instances,
|
|
nodes: domainResult.nodes,
|
|
messages: domainResult.messages,
|
|
selectedDomain: _selectedDomain,
|
|
);
|
|
}
|
|
|
|
/// Loads nodes and messages for a domain, persisting the selection.
|
|
Future<DomainLoadResult> loadDomainData({
|
|
required String domain,
|
|
ProgressCallback? onProgress,
|
|
http.Client? httpClient,
|
|
bool forceFull = false,
|
|
}) async {
|
|
final store = await _ensureStore();
|
|
final targetDomain =
|
|
domain.trim().isEmpty ? 'potatomesh.net' : domain.trim();
|
|
|
|
final client = httpClient ?? _client ?? http.Client();
|
|
final shouldClose = httpClient == null && _client == null;
|
|
|
|
try {
|
|
final nodes = await _fetchNodesList(
|
|
domain: targetDomain,
|
|
client: client,
|
|
persist: true,
|
|
useCacheWhenAvailable: !forceFull,
|
|
onProgress: onProgress,
|
|
);
|
|
|
|
final messages = await _loadMessagesInternal(
|
|
domain: targetDomain,
|
|
client: client,
|
|
forceFull: forceFull,
|
|
onProgress: onProgress,
|
|
);
|
|
|
|
_selectedDomain = targetDomain;
|
|
await store.saveSelectedDomain(_selectedDomain);
|
|
|
|
return DomainLoadResult(
|
|
domain: targetDomain,
|
|
nodes: nodes,
|
|
messages: messages,
|
|
);
|
|
} finally {
|
|
if (shouldClose) {
|
|
client.close();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fetches a complete messages list on first load, falling back to a smaller
|
|
/// refresh window on subsequent calls.
|
|
Future<List<MeshMessage>> loadMessages({required String domain}) async {
|
|
await _ensureStore();
|
|
final key = _domainKey(domain);
|
|
final loaded = _messagesLoaded[key] ?? false;
|
|
final client = _client ?? http.Client();
|
|
final shouldClose = _client == null;
|
|
try {
|
|
// Ensure cached data is available for immediate rendering.
|
|
if (!loaded && !_messagesByDomain.containsKey(key)) {
|
|
final cached = _store?.loadMessages(domain) ?? const [];
|
|
if (cached.isNotEmpty) {
|
|
_messagesByDomain[key] = cached;
|
|
}
|
|
}
|
|
return _loadMessagesInternal(
|
|
domain: domain,
|
|
client: client,
|
|
forceFull: !loaded,
|
|
);
|
|
} finally {
|
|
if (shouldClose) {
|
|
client.close();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns any messages that arrived after the last recorded entry for a domain.
|
|
Future<List<MeshMessage>> detectUnseenMessages({
|
|
required String domain,
|
|
required List<MeshMessage> messages,
|
|
}) async {
|
|
final tracker = await _ensureTracker();
|
|
return tracker.unseenSince(domain: domain, messages: messages);
|
|
}
|
|
|
|
/// Stores a nodes snapshot for quick lookup without refetching mid-session.
|
|
Future<List<MeshNode>> loadNodes({required String domain}) async {
|
|
await _ensureStore();
|
|
final key = _domainKey(domain);
|
|
if (_nodesByDomain.containsKey(key)) {
|
|
return _nodesByDomain[key]!;
|
|
}
|
|
|
|
final cached = _store?.loadNodes(domain) ?? const [];
|
|
if (cached.isNotEmpty) {
|
|
_nodesByDomain[key] = cached;
|
|
NodeShortNameCache.instance.prime(domain: domain, nodes: cached);
|
|
return cached;
|
|
}
|
|
|
|
final client = _client ?? http.Client();
|
|
final shouldClose = _client == null;
|
|
try {
|
|
return _fetchNodesList(
|
|
domain: domain,
|
|
client: client,
|
|
persist: true,
|
|
useCacheWhenAvailable: true,
|
|
);
|
|
} finally {
|
|
if (shouldClose) {
|
|
client.close();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Public entry point for fetching and caching federation instances.
|
|
Future<List<MeshInstance>> discoverInstances({
|
|
http.Client? client,
|
|
ProgressCallback? onProgress,
|
|
}) async {
|
|
final store = await _ensureStore();
|
|
final cached = store.loadInstances();
|
|
final httpClient = client ?? _client ?? http.Client();
|
|
final shouldClose = client == null && _client == null;
|
|
try {
|
|
final discovered = await _discoverInstances(
|
|
client: httpClient,
|
|
onProgress: onProgress,
|
|
);
|
|
final validated = await _validateInstances(
|
|
discovered,
|
|
httpClient,
|
|
onProgress,
|
|
);
|
|
if (validated.isNotEmpty) {
|
|
_instances = validated;
|
|
await store.saveInstances(validated);
|
|
return validated;
|
|
}
|
|
if (cached.isNotEmpty) return cached;
|
|
return discovered;
|
|
} finally {
|
|
if (shouldClose) {
|
|
httpClient.close();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Overwrites the cached instances and persists them to local storage.
|
|
Future<void> updateInstances(List<MeshInstance> instances) async {
|
|
_instances = instances;
|
|
final store = await _ensureStore();
|
|
await store.saveInstances(instances);
|
|
}
|
|
|
|
@override
|
|
MeshNode? findNode(String domain, String nodeId) {
|
|
final key = _domainKey(domain);
|
|
final nodes = _nodesByDomain[key];
|
|
if (nodes == null) return null;
|
|
final trimmed = nodeId.trim();
|
|
for (final node in nodes) {
|
|
if (_matchesNodeId(node.nodeId, trimmed)) {
|
|
return node;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Future<List<MeshInstance>> _discoverInstances({
|
|
required http.Client client,
|
|
ProgressCallback? onProgress,
|
|
}) async {
|
|
final seen = <String>{};
|
|
final queue = Queue<String>();
|
|
final results = <MeshInstance>[];
|
|
|
|
Future<void> enqueueFromDomain(String domain) async {
|
|
try {
|
|
final uri = _buildInstancesUri(domain);
|
|
_logHttp('GET $uri');
|
|
final resp = await client.get(uri).timeout(_requestTimeout);
|
|
_logHttp('HTTP ${resp.statusCode} $uri');
|
|
if (resp.statusCode != 200) return;
|
|
final decoded = await _decodeJsonList(resp.body);
|
|
final parsed = decoded
|
|
.map(MeshInstance.fromJson)
|
|
.where((instance) => instance.domain.isNotEmpty)
|
|
.toList();
|
|
for (final instance in parsed) {
|
|
final key = _domainKey(instance.domain);
|
|
if (seen.contains(key)) continue;
|
|
seen.add(key);
|
|
results.add(instance);
|
|
queue.add(instance.domain);
|
|
}
|
|
} catch (_) {
|
|
// Skip unreachable domains during discovery.
|
|
}
|
|
}
|
|
|
|
onProgress?.call(const BootstrapProgress(stage: 'loading instances'));
|
|
await enqueueFromDomain('potatomesh.net');
|
|
|
|
while (queue.isNotEmpty) {
|
|
final domain = queue.removeFirst();
|
|
onProgress?.call(
|
|
BootstrapProgress(
|
|
stage: 'discovering instances',
|
|
current: results.length,
|
|
total: null,
|
|
detail: domain,
|
|
),
|
|
);
|
|
await enqueueFromDomain(domain);
|
|
}
|
|
|
|
final deduped = <String, MeshInstance>{};
|
|
for (final instance in results) {
|
|
final key = _domainKey(instance.domain);
|
|
if (instance.isPrivate) continue;
|
|
deduped[key] = instance;
|
|
}
|
|
|
|
final list = deduped.values.toList()
|
|
..sort((a, b) =>
|
|
a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()));
|
|
return list;
|
|
}
|
|
|
|
Future<List<MeshInstance>> _validateInstances(
|
|
List<MeshInstance> candidates,
|
|
http.Client client,
|
|
ProgressCallback? onProgress,
|
|
) async {
|
|
if (candidates.isEmpty) return const [];
|
|
final now = DateTime.now().toUtc();
|
|
final valid = <MeshInstance>[];
|
|
final total = candidates.length;
|
|
for (var i = 0; i < candidates.length; i++) {
|
|
final candidate = candidates[i];
|
|
onProgress?.call(
|
|
BootstrapProgress(
|
|
stage: 'verifying instances',
|
|
current: i + 1,
|
|
total: total,
|
|
detail: candidate.domain,
|
|
),
|
|
);
|
|
try {
|
|
final nodes = await _fetchNodesList(
|
|
domain: candidate.domain,
|
|
client: client,
|
|
limit: 200,
|
|
persist: false,
|
|
useCacheWhenAvailable: false,
|
|
);
|
|
final active = nodes
|
|
.where((node) => node.isActive(const Duration(hours: 24), now))
|
|
.toList();
|
|
if (active.length >= 10) {
|
|
valid.add(candidate);
|
|
}
|
|
} catch (_) {
|
|
// Invalid instance; skip.
|
|
}
|
|
}
|
|
return valid.isNotEmpty ? valid : candidates;
|
|
}
|
|
|
|
Future<DomainLoadResult> _loadFirstResponsiveInstance({
|
|
required String preferredDomain,
|
|
required List<MeshInstance> candidates,
|
|
required http.Client httpClient,
|
|
ProgressCallback? onProgress,
|
|
}) async {
|
|
final store = await _ensureStore();
|
|
final ordered = <String>{
|
|
preferredDomain,
|
|
...candidates.map((c) => c.domain)
|
|
};
|
|
DomainLoadResult? result;
|
|
Object? lastError;
|
|
for (final domain in ordered) {
|
|
try {
|
|
result = await loadDomainData(
|
|
domain: domain,
|
|
onProgress: onProgress,
|
|
httpClient: httpClient,
|
|
forceFull: true,
|
|
);
|
|
break;
|
|
} catch (error) {
|
|
lastError = error;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (result != null) {
|
|
return result;
|
|
}
|
|
|
|
final cachedNodes = store.loadNodes(preferredDomain);
|
|
final cachedMessages = store.loadMessages(preferredDomain);
|
|
if (cachedNodes.isNotEmpty || cachedMessages.isNotEmpty) {
|
|
_selectedDomain = preferredDomain;
|
|
await store.saveSelectedDomain(_selectedDomain);
|
|
return DomainLoadResult(
|
|
domain: preferredDomain,
|
|
nodes: cachedNodes,
|
|
messages: cachedMessages,
|
|
);
|
|
}
|
|
|
|
throw lastError ?? Exception('No responsive instances');
|
|
}
|
|
|
|
String _resolveSelectedDomain(
|
|
List<MeshInstance> available,
|
|
String desired,
|
|
) {
|
|
if (available.isEmpty) {
|
|
return desired.trim().isNotEmpty ? desired.trim() : 'potatomesh.net';
|
|
}
|
|
final desiredKey = _domainKey(desired);
|
|
for (final instance in available) {
|
|
if (_domainKey(instance.domain) == desiredKey) {
|
|
return instance.domain;
|
|
}
|
|
}
|
|
return available[_random.nextInt(available.length)].domain;
|
|
}
|
|
|
|
Future<List<MeshNode>> _fetchNodesList({
|
|
required String domain,
|
|
required http.Client client,
|
|
int limit = 1000,
|
|
bool persist = true,
|
|
bool useCacheWhenAvailable = true,
|
|
ProgressCallback? onProgress,
|
|
}) async {
|
|
final key = _domainKey(domain);
|
|
if (useCacheWhenAvailable && _nodesByDomain.containsKey(key)) {
|
|
return _nodesByDomain[key]!;
|
|
}
|
|
final uri = _buildNodesUri(domain, limit: limit);
|
|
_logHttp('GET $uri');
|
|
final resp = await client.get(uri).timeout(_requestTimeout);
|
|
_logHttp('HTTP ${resp.statusCode} $uri');
|
|
if (resp.statusCode != 200) {
|
|
throw Exception('HTTP ${resp.statusCode}: ${resp.body}');
|
|
}
|
|
final decoded = await _decodeJsonList(resp.body);
|
|
final nodes = <MeshNode>[];
|
|
var index = 0;
|
|
for (final entry in decoded) {
|
|
index += 1;
|
|
final node = MeshNode.fromJson(entry);
|
|
if (node.nodeId.isEmpty) continue;
|
|
nodes.add(node);
|
|
onProgress?.call(
|
|
BootstrapProgress(
|
|
stage: 'loading nodes',
|
|
current: index,
|
|
total: decoded.length,
|
|
detail: domain,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (persist) {
|
|
_nodesByDomain[key] = nodes;
|
|
NodeShortNameCache.instance.prime(domain: domain, nodes: nodes);
|
|
await _store?.saveNodes(domain, nodes);
|
|
}
|
|
|
|
return nodes;
|
|
}
|
|
|
|
Future<List<MeshMessage>> _loadMessagesInternal({
|
|
required String domain,
|
|
required http.Client client,
|
|
bool forceFull = false,
|
|
ProgressCallback? onProgress,
|
|
}) async {
|
|
final key = _domainKey(domain);
|
|
final alreadyLoaded = _messagesLoaded[key] ?? false;
|
|
final initialFetch = forceFull || !alreadyLoaded;
|
|
final limit = initialFetch ? 1000 : 100;
|
|
|
|
final uri = _buildMessagesUri(domain, limit: limit);
|
|
_logHttp('GET $uri');
|
|
final resp = await client.get(uri).timeout(_requestTimeout);
|
|
_logHttp('HTTP ${resp.statusCode} $uri');
|
|
if (resp.statusCode != 200) {
|
|
throw Exception('HTTP ${resp.statusCode}: ${resp.body}');
|
|
}
|
|
final decoded = await _decodeJsonList(resp.body);
|
|
|
|
final messages = <MeshMessage>[];
|
|
var index = 0;
|
|
for (final entry in decoded) {
|
|
index += 1;
|
|
final message = MeshMessage.fromJson(entry);
|
|
messages.add(message);
|
|
onProgress?.call(
|
|
BootstrapProgress(
|
|
stage: 'loading messages',
|
|
current: index,
|
|
total: decoded.length,
|
|
detail: domain,
|
|
),
|
|
);
|
|
}
|
|
|
|
final merged = _mergeMessages(domain, messages);
|
|
_messagesLoaded[key] = true;
|
|
await _store?.saveMessages(domain, merged);
|
|
|
|
// Ensure new senders are cached locally for name lookups.
|
|
await _hydrateMissingNodes(
|
|
domain: domain, messages: messages, client: client);
|
|
|
|
return merged;
|
|
}
|
|
|
|
List<MeshMessage> _mergeMessages(String domain, List<MeshMessage> incoming) {
|
|
final key = _domainKey(domain);
|
|
final existing = List<MeshMessage>.from(_messagesByDomain[key] ?? const []);
|
|
final seen = existing.map(MessageSeenTracker.messageKey).toSet();
|
|
for (final msg in incoming) {
|
|
final key = MessageSeenTracker.messageKey(msg);
|
|
if (seen.contains(key)) {
|
|
continue;
|
|
}
|
|
existing.add(msg);
|
|
seen.add(key);
|
|
}
|
|
final sorted = sortMessagesByRxTime(existing);
|
|
if (sorted.length > 1200) {
|
|
sorted.removeRange(0, sorted.length - 1200);
|
|
}
|
|
_messagesByDomain[key] = sorted;
|
|
return sorted;
|
|
}
|
|
|
|
Future<void> _hydrateMissingNodes({
|
|
required String domain,
|
|
required List<MeshMessage> messages,
|
|
required http.Client client,
|
|
}) async {
|
|
final store = await _ensureStore();
|
|
final key = _domainKey(domain);
|
|
var nodes = List<MeshNode>.from(_nodesByDomain[key] ?? const []);
|
|
if (nodes.isEmpty) {
|
|
final cached = store.loadNodes(domain);
|
|
if (cached.isNotEmpty) {
|
|
nodes = List<MeshNode>.from(cached);
|
|
_nodesByDomain[key] = nodes;
|
|
NodeShortNameCache.instance.prime(domain: domain, nodes: cached);
|
|
}
|
|
}
|
|
final knownIds = nodes.map((n) => _normalizeNodeId(n.nodeId)).toSet();
|
|
final inFlight = _nodeFetchInFlight.putIfAbsent(key, () => {});
|
|
for (final message in messages) {
|
|
final rawNodeId = message.lookupNodeId.trim();
|
|
final nodeId = _normalizeNodeId(rawNodeId);
|
|
if (nodeId.isEmpty || knownIds.contains(nodeId)) continue;
|
|
if (inFlight.contains(nodeId)) continue;
|
|
inFlight.add(nodeId);
|
|
try {
|
|
final uri = _buildNodeUri(domain, rawNodeId);
|
|
_logHttp('GET $uri');
|
|
final resp = await client.get(uri).timeout(_requestTimeout);
|
|
_logHttp('HTTP ${resp.statusCode} $uri');
|
|
if (resp.statusCode != 200) {
|
|
knownIds.add(nodeId);
|
|
continue;
|
|
}
|
|
final decoded = await _decodeJsonMap(resp.body);
|
|
final node = MeshNode.fromJson(decoded);
|
|
if (node.nodeId.isEmpty) continue;
|
|
nodes = List<MeshNode>.from(nodes)..add(node);
|
|
_nodesByDomain[key] = nodes;
|
|
NodeShortNameCache.instance.prime(domain: domain, nodes: [node]);
|
|
await _store?.saveNodes(domain, nodes);
|
|
knownIds.add(_normalizeNodeId(node.nodeId));
|
|
} catch (_) {
|
|
// Swallow node lookup errors during refresh.
|
|
} finally {
|
|
inFlight.remove(nodeId);
|
|
}
|
|
}
|
|
}
|
|
|
|
bool _matchesNodeId(String existing, String candidate) {
|
|
final cleanExisting = _normalizeNodeId(existing);
|
|
final cleanCandidate = _normalizeNodeId(candidate);
|
|
return cleanExisting.trim() == cleanCandidate.trim();
|
|
}
|
|
|
|
String _normalizeNodeId(String id) {
|
|
return id.startsWith('!') ? id.substring(1) : id;
|
|
}
|
|
}
|
|
|
|
/// Tracks and persists the most recently seen message per domain.
|
|
class MessageSeenTracker {
|
|
MessageSeenTracker(this._store);
|
|
|
|
final MeshLocalStore _store;
|
|
|
|
/// Returns unseen messages that arrived after the last recorded message,
|
|
/// while updating the persisted marker to the newest entry.
|
|
Future<List<MeshMessage>> unseenSince({
|
|
required String domain,
|
|
required List<MeshMessage> messages,
|
|
}) async {
|
|
if (messages.isEmpty) return const [];
|
|
|
|
final lastSeen = _store.loadLastSeenMessageKey(domain);
|
|
final ordered = sortMessagesByRxTime(List<MeshMessage>.from(messages));
|
|
final latestKey = messageKey(ordered.last);
|
|
await _store.saveLastSeenMessageKey(domain, latestKey);
|
|
|
|
if (lastSeen == null || lastSeen.isEmpty) {
|
|
return const [];
|
|
}
|
|
|
|
var markerFound = false;
|
|
final unseen = <MeshMessage>[];
|
|
for (final message in ordered) {
|
|
if (markerFound) {
|
|
unseen.add(message);
|
|
continue;
|
|
}
|
|
if (messageKey(message) == lastSeen) {
|
|
markerFound = true;
|
|
}
|
|
}
|
|
|
|
return markerFound ? unseen : const [];
|
|
}
|
|
|
|
/// Returns the stable key used to detect repeated messages.
|
|
static String messageKey(MeshMessage msg) {
|
|
return '${msg.id}-${msg.rxIso}-${msg.fromId}-${msg.text}';
|
|
}
|
|
}
|
|
|
|
/// Displays the fetched mesh messages and supports pull-to-refresh.
|
|
class MessagesScreen extends StatefulWidget {
|
|
const MessagesScreen({
|
|
super.key,
|
|
this.fetcher = fetchMessages,
|
|
this.onOpenSettings,
|
|
this.resetToken = 0,
|
|
required this.domain,
|
|
this.repository,
|
|
this.initialMessages = const [],
|
|
this.instanceName,
|
|
this.enableAutoRefresh = true,
|
|
this.notificationClient = const NoopNotificationClient(),
|
|
});
|
|
|
|
/// Fetch function used to load messages from the PotatoMesh API.
|
|
final Future<List<MeshMessage>> Function() fetcher;
|
|
|
|
/// Handler invoked when the settings icon is tapped.
|
|
final void Function(BuildContext context)? onOpenSettings;
|
|
|
|
/// 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;
|
|
|
|
/// Optional repository backing persistence for this screen.
|
|
final MeshRepository? repository;
|
|
|
|
/// Messages obtained during the bootstrap phase to avoid re-fetching.
|
|
final List<MeshMessage> initialMessages;
|
|
|
|
/// Human-friendly name of the selected instance if the user picked one.
|
|
final String? instanceName;
|
|
|
|
/// Whether periodic background refresh is enabled.
|
|
final bool enableAutoRefresh;
|
|
|
|
/// Client used to deliver local notifications for unseen messages.
|
|
final NotificationClient notificationClient;
|
|
|
|
@override
|
|
State<MessagesScreen> createState() => _MessagesScreenState();
|
|
}
|
|
|
|
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;
|
|
late final NotificationClient _notificationClient;
|
|
late final Future<void> _notificationReady;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_notificationClient = widget.notificationClient;
|
|
_notificationReady = _notificationClient.initialize();
|
|
_messages = List<MeshMessage>.from(widget.initialMessages);
|
|
_future = Future.value(_messages);
|
|
_startFetch(clear: _messages.isEmpty);
|
|
WidgetsBinding.instance.addObserver(this);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_refresh();
|
|
_startAutoRefresh();
|
|
});
|
|
}
|
|
|
|
/// When the fetcher changes, reload the future so the widget reflects the
|
|
/// new data source on rebuilds.
|
|
@override
|
|
void didUpdateWidget(covariant MessagesScreen oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.notificationClient != widget.notificationClient) {
|
|
_notificationClient = widget.notificationClient;
|
|
_notificationReady = _notificationClient.initialize();
|
|
}
|
|
if (oldWidget.fetcher != widget.fetcher ||
|
|
oldWidget.resetToken != widget.resetToken ||
|
|
oldWidget.enableAutoRefresh != widget.enableAutoRefresh) {
|
|
_restartAutoRefresh();
|
|
setState(() {
|
|
_messages = List<MeshMessage>.from(widget.initialMessages);
|
|
_future = Future.value(_messages);
|
|
});
|
|
_startFetch(clear: _messages.isEmpty);
|
|
}
|
|
}
|
|
|
|
@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();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Reloads the message feed and waits for completion for pull-to-refresh.
|
|
///
|
|
/// Errors are intentionally swallowed so the [FutureBuilder] can surface them
|
|
/// via its `snapshot.error` state without bubbling an exception to the
|
|
/// gesture handler.
|
|
Future<void> _refresh({bool appendOnly = false}) async {
|
|
await _startFetch(appendOnly: appendOnly);
|
|
}
|
|
|
|
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 MessageSeenTracker.messageKey(msg);
|
|
}
|
|
|
|
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() {
|
|
if (!widget.enableAutoRefresh) return;
|
|
_refreshTimer?.cancel();
|
|
if (!_isForeground) return;
|
|
_refreshTimer = Timer.periodic(
|
|
const Duration(seconds: 60),
|
|
(_) => _refresh(appendOnly: true),
|
|
);
|
|
}
|
|
|
|
void _restartAutoRefresh() {
|
|
if (_isForeground) {
|
|
_startAutoRefresh();
|
|
}
|
|
}
|
|
|
|
Future<void> _notifyUnseenMessages(List<MeshMessage> fetched) async {
|
|
final repo = widget.repository;
|
|
if (repo == null || fetched.isEmpty) return;
|
|
try {
|
|
final unseen = await repo.detectUnseenMessages(
|
|
domain: widget.domain,
|
|
messages: fetched,
|
|
);
|
|
if (_isForeground || unseen.isEmpty) {
|
|
return;
|
|
}
|
|
await _notificationReady;
|
|
for (final message in unseen) {
|
|
final sender = NodeShortNameCache.fallbackShortName(
|
|
message.lookupNodeId.isNotEmpty
|
|
? message.lookupNodeId
|
|
: message.fromId,
|
|
);
|
|
await _notificationClient.showNewMessage(
|
|
message: message,
|
|
domain: widget.domain,
|
|
senderShortName: sender,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
debugPrint('D/Notification error: $error');
|
|
}
|
|
}
|
|
|
|
Future<void> _startFetch(
|
|
{bool clear = false, bool appendOnly = false}) async {
|
|
final version = ++_fetchVersion;
|
|
final future = widget.fetcher();
|
|
if (!appendOnly) {
|
|
setState(() {
|
|
if (clear) {
|
|
_messages = const [];
|
|
}
|
|
_future = future;
|
|
});
|
|
}
|
|
try {
|
|
final msgs = await future;
|
|
if (version != _fetchVersion) return;
|
|
_appendMessages(msgs);
|
|
await _notifyUnseenMessages(msgs);
|
|
} catch (error) {
|
|
if (appendOnly) {
|
|
debugPrint('D/Failed to append messages: $error');
|
|
return;
|
|
}
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
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';
|
|
}
|
|
|
|
Color _zebraColor(BuildContext context, int index) {
|
|
final isEven = index.isEven;
|
|
final brightness = Theme.of(context).brightness;
|
|
if (brightness == Brightness.dark) {
|
|
return isEven
|
|
? Colors.black
|
|
: Color.lerp(Colors.black, Colors.white, 0.05)!;
|
|
}
|
|
return isEven
|
|
? Colors.white
|
|
: Color.lerp(Colors.white, Colors.black, 0.05)!;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final titleText =
|
|
(widget.instanceName != null && widget.instanceName!.trim().isNotEmpty)
|
|
? '🥔 ${widget.instanceName!.trim()}'
|
|
: '🥔 PotatoMesh Reader';
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
leading: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
child: SvgPicture.asset(
|
|
'assets/potatomesh-logo.svg',
|
|
height: 28,
|
|
semanticsLabel: 'PotatoMesh logo',
|
|
),
|
|
),
|
|
title: Text(titleText),
|
|
actions: [
|
|
IconButton(
|
|
tooltip: 'Refresh',
|
|
icon: const Icon(Icons.refresh),
|
|
onPressed: _refresh,
|
|
),
|
|
IconButton(
|
|
tooltip: 'Settings',
|
|
icon: const Icon(Icons.settings),
|
|
onPressed: () {
|
|
if (widget.onOpenSettings != null) {
|
|
widget.onOpenSettings!(context);
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
body: FutureBuilder<List<MeshMessage>>(
|
|
future: _future,
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState == ConnectionState.waiting &&
|
|
_messages.isEmpty) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
if (snapshot.hasError && _messages.isEmpty) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Text(
|
|
'Failed to load messages:\n${snapshot.error}',
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
final messages = _messages;
|
|
if (messages.isEmpty) {
|
|
return const Center(child: Text('No messages yet.'));
|
|
}
|
|
|
|
return RefreshIndicator(
|
|
onRefresh: _refresh,
|
|
child: ScrollConfiguration(
|
|
behavior: const ScrollBehavior().copyWith(scrollbars: false),
|
|
child: ListView.builder(
|
|
controller: _scrollController,
|
|
padding: EdgeInsets.zero,
|
|
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;
|
|
final zebraColor = _zebraColor(context, index);
|
|
final content = needsDivider
|
|
? Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
DateDivider(label: currentLabel),
|
|
ChatLine(
|
|
message: msg,
|
|
domain: widget.domain,
|
|
),
|
|
],
|
|
)
|
|
: ChatLine(
|
|
message: msg,
|
|
domain: widget.domain,
|
|
);
|
|
return Container(color: zebraColor, child: content);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Individual chat line styled in IRC-inspired format.
|
|
class ChatLine extends StatelessWidget {
|
|
const ChatLine({
|
|
super.key,
|
|
required this.message,
|
|
required this.domain,
|
|
});
|
|
|
|
static final Map<String, double> _indentCache = {};
|
|
|
|
/// 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) {
|
|
final h = nick.codeUnits.fold<int>(0, (a, b) => (a + b) % 360);
|
|
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 key =
|
|
'${baseStyle.fontFamily}-${baseStyle.fontSize}-${baseStyle.fontWeight}-${baseStyle.fontStyle}';
|
|
final cached = _indentCache[key];
|
|
if (cached != null) return cached;
|
|
final painter = TextPainter(
|
|
text: TextSpan(text: ' ', style: baseStyle),
|
|
textDirection: Directionality.of(context),
|
|
)..layout();
|
|
final width = painter.size.width * 8;
|
|
_indentCache[key] = width;
|
|
return width;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final timeStr = '[${message.timeFormatted}]';
|
|
final rawId = message.fromId.isNotEmpty ? message.fromId : '?';
|
|
final lookupId =
|
|
message.lookupNodeId.isNotEmpty ? message.lookupNodeId : rawId;
|
|
final nick = rawId.startsWith('!') ? rawId : '!$rawId';
|
|
final channel = '#${message.channelName ?? ''}'.trim();
|
|
final bodyText = message.text.isEmpty ? '⟂ (no text)' : message.text;
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final baseStyle = DefaultTextStyle.of(context)
|
|
.style
|
|
.copyWith(color: colorScheme.onSurface);
|
|
final linkStyle = baseStyle.copyWith(
|
|
color: colorScheme.tertiary,
|
|
decoration: TextDecoration.underline,
|
|
);
|
|
final indentPx = _computeIndentPixels(baseStyle, context);
|
|
|
|
return FutureBuilder<String>(
|
|
future: NodeShortNameCache.instance.shortNameFor(
|
|
domain: domain,
|
|
nodeId: lookupId,
|
|
),
|
|
builder: (context, snapshot) {
|
|
final shortName = snapshot.data?.isNotEmpty == true
|
|
? snapshot.data!
|
|
: _fallbackShortName(lookupId);
|
|
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: TextStyle(
|
|
color: Theme.of(context).colorScheme.tertiary),
|
|
),
|
|
],
|
|
style: baseStyle,
|
|
),
|
|
),
|
|
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) {
|
|
final color = Theme.of(context).colorScheme.onSurfaceVariant;
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
|
|
child: Text(
|
|
'-- $label --',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w700,
|
|
color: color,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// MVP settings view offering endpoint selection and about info.
|
|
class SettingsScreen extends StatefulWidget {
|
|
const SettingsScreen({
|
|
super.key,
|
|
required this.currentDomain,
|
|
required this.onDomainChanged,
|
|
this.loadInstances = _defaultInstanceLoader,
|
|
this.themeMode = ThemeMode.system,
|
|
this.onThemeChanged,
|
|
});
|
|
|
|
/// Currently selected endpoint domain.
|
|
final String currentDomain;
|
|
|
|
/// Callback fired when the user changes the endpoint.
|
|
final ValueChanged<String> onDomainChanged;
|
|
|
|
/// Loader used to fetch federation instance metadata.
|
|
final Future<List<MeshInstance>> Function({bool refresh}) loadInstances;
|
|
|
|
/// Current theme mode selection.
|
|
final ThemeMode themeMode;
|
|
|
|
/// Callback when the theme selection changes.
|
|
final ValueChanged<ThemeMode>? onThemeChanged;
|
|
|
|
static Future<List<MeshInstance>> _defaultInstanceLoader(
|
|
{bool refresh = false}) {
|
|
return fetchInstances();
|
|
}
|
|
|
|
@override
|
|
State<SettingsScreen> createState() => _SettingsScreenState();
|
|
}
|
|
|
|
class _SettingsScreenState extends State<SettingsScreen> {
|
|
static const String _defaultDomain = 'potatomesh.net';
|
|
static const String _defaultName = 'BerlinMesh';
|
|
List<MeshInstance> _instances = const [];
|
|
bool _loading = false;
|
|
String _selectedDomain = '';
|
|
String? _error;
|
|
String _versionLabel = '';
|
|
Future<InstanceVersion?>? _instanceVersionFuture;
|
|
late ThemeMode _selectedThemeMode;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_selectedDomain = widget.currentDomain;
|
|
_selectedThemeMode = widget.themeMode;
|
|
_fetchInstances();
|
|
_loadVersion();
|
|
_instanceVersionFuture =
|
|
InstanceVersionCache.instance.fetch(domain: _selectedDomain);
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant SettingsScreen oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.currentDomain != widget.currentDomain) {
|
|
_selectedDomain = widget.currentDomain;
|
|
_instanceVersionFuture =
|
|
InstanceVersionCache.instance.fetch(domain: _selectedDomain);
|
|
}
|
|
if (oldWidget.themeMode != widget.themeMode) {
|
|
_selectedThemeMode = widget.themeMode;
|
|
}
|
|
}
|
|
|
|
Future<void> _fetchInstances({bool refresh = false}) async {
|
|
setState(() {
|
|
_loading = true;
|
|
_error = null;
|
|
});
|
|
|
|
try {
|
|
final fetched = await widget.loadInstances(refresh: refresh);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_instances = fetched;
|
|
});
|
|
} catch (error) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_instances = const [];
|
|
_error = error.toString();
|
|
});
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_loading = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
setState(() {
|
|
_selectedDomain = domain;
|
|
});
|
|
widget.onDomainChanged(domain);
|
|
setState(() {
|
|
_instanceVersionFuture =
|
|
InstanceVersionCache.instance.fetch(domain: _selectedDomain);
|
|
});
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Endpoint set to $domain')),
|
|
);
|
|
}
|
|
|
|
void _onThemeModeChanged(ThemeMode? mode) {
|
|
if (mode == null) return;
|
|
setState(() {
|
|
_selectedThemeMode = mode;
|
|
});
|
|
widget.onThemeChanged?.call(mode);
|
|
}
|
|
|
|
List<DropdownMenuItem<String>> _buildEndpointOptions() {
|
|
final seen = <String, String>{};
|
|
void addOption(String domain, String name) {
|
|
final key = domain.trim();
|
|
if (key.isEmpty || seen.containsKey(key)) return;
|
|
seen[key] = name;
|
|
}
|
|
|
|
addOption(_defaultDomain, _defaultName);
|
|
for (final instance in _instances) {
|
|
addOption(instance.domain, instance.displayName);
|
|
}
|
|
if (_selectedDomain.isNotEmpty && !seen.containsKey(_selectedDomain)) {
|
|
addOption(_selectedDomain, 'Custom (${_selectedDomain.trim()})');
|
|
}
|
|
|
|
final sortedKeys = seen.keys.toList()
|
|
..sort(
|
|
(a, b) => seen[a]!.toLowerCase().compareTo(seen[b]!.toLowerCase()));
|
|
|
|
return sortedKeys
|
|
.map(
|
|
(domain) => DropdownMenuItem<String>(
|
|
value: domain,
|
|
child: Text(seen[domain]!),
|
|
),
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final endpointItems = _buildEndpointOptions();
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('Settings')),
|
|
body: ListView(
|
|
children: [
|
|
ListTile(
|
|
leading: const Icon(Icons.cloud),
|
|
title: const Text('Endpoint'),
|
|
subtitle: Text('$_selectedDomain/api/messages'),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: DropdownButtonFormField<String>(
|
|
key: ValueKey<String>(_selectedDomain),
|
|
initialValue: _selectedDomain.isNotEmpty
|
|
? _selectedDomain
|
|
: _defaultDomain,
|
|
isExpanded: true,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Select endpoint',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
items: endpointItems,
|
|
onChanged: _loading ? null : _onEndpointChanged,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
IconButton(
|
|
tooltip: 'Refresh instances',
|
|
icon: const Icon(Icons.refresh),
|
|
onPressed: _loading
|
|
? null
|
|
: () => _fetchInstances(refresh: true),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
if (_loading)
|
|
const LinearProgressIndicator()
|
|
else if (_error != null)
|
|
Text(
|
|
'Failed to load instances: $_error',
|
|
style: const TextStyle(color: Colors.redAccent),
|
|
)
|
|
else if (_instances.isEmpty)
|
|
const Text('No federation instances returned.'),
|
|
],
|
|
),
|
|
),
|
|
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('Instance'),
|
|
subtitle: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
subtitle,
|
|
const SizedBox(height: 4),
|
|
RichText(
|
|
text: TextSpan(
|
|
text: domainDisplay,
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.primary,
|
|
decoration: TextDecoration.underline,
|
|
),
|
|
recognizer: TapGestureRecognizer()
|
|
..onTap = () async {
|
|
if (domainUri != null) {
|
|
await launchUrl(
|
|
domainUri,
|
|
mode: LaunchMode.externalApplication,
|
|
);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
const Divider(),
|
|
const ListTile(
|
|
leading: Icon(Icons.palette_outlined),
|
|
title: Text('Theme'),
|
|
subtitle: Text('Select preferred appearance'),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
|
child: DropdownButtonFormField<ThemeMode>(
|
|
decoration: const InputDecoration(
|
|
border: OutlineInputBorder(),
|
|
labelText: 'Appearance',
|
|
),
|
|
initialValue: _selectedThemeMode,
|
|
items: const [
|
|
DropdownMenuItem(
|
|
value: ThemeMode.system,
|
|
child: Text('System'),
|
|
),
|
|
DropdownMenuItem(
|
|
value: ThemeMode.light,
|
|
child: Text('Light'),
|
|
),
|
|
DropdownMenuItem(
|
|
value: ThemeMode.dark,
|
|
child: Text('Dark'),
|
|
),
|
|
],
|
|
onChanged: _onThemeModeChanged,
|
|
),
|
|
),
|
|
const Divider(),
|
|
const ListTile(
|
|
leading: Icon(Icons.info_outline),
|
|
title: Text('About'),
|
|
subtitle: Text(
|
|
'🥔 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: TextStyle(
|
|
color: Theme.of(context).colorScheme.primary,
|
|
decoration: TextDecoration.underline,
|
|
),
|
|
recognizer: TapGestureRecognizer()
|
|
..onTap = () async {
|
|
final uri = Uri.parse(
|
|
'https://github.com/l5yth/potato-mesh/',
|
|
);
|
|
await launchUrl(
|
|
uri,
|
|
mode: LaunchMode.externalApplication,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// --- 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 nodeId;
|
|
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,
|
|
this.nodeId = '',
|
|
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() ?? '',
|
|
nodeId: json['node_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;
|
|
}
|
|
|
|
/// Prefer the explicit node id when present, falling back to the sender id.
|
|
String get lookupNodeId => nodeId.isNotEmpty ? nodeId : fromId;
|
|
|
|
/// Serialises the message for persistence in local storage.
|
|
Map<String, dynamic> toJson() {
|
|
return {
|
|
'id': id,
|
|
'rx_iso': rxIso,
|
|
'from_id': fromId,
|
|
'node_id': nodeId,
|
|
'to_id': toId,
|
|
'channel': channel,
|
|
'channel_name': channelName,
|
|
'portnum': portnum,
|
|
'text': text,
|
|
'rssi': rssi,
|
|
'snr': snr,
|
|
'hop_limit': hopLimit,
|
|
};
|
|
}
|
|
}
|
|
|
|
/// Mesh federation instance metadata used to configure endpoints.
|
|
class MeshInstance {
|
|
const MeshInstance({
|
|
required this.name,
|
|
required this.domain,
|
|
this.id = '',
|
|
this.isPrivate = false,
|
|
this.lastUpdateTime,
|
|
});
|
|
|
|
/// Human-friendly instance name.
|
|
final String name;
|
|
|
|
/// Instance domain hosting the PotatoMesh API.
|
|
final String domain;
|
|
|
|
/// Unique identifier for the instance when provided by the API.
|
|
final String id;
|
|
|
|
/// True when the instance is marked as private and should be hidden.
|
|
final bool isPrivate;
|
|
|
|
/// Optional last update timestamp from the federation payload.
|
|
final DateTime? lastUpdateTime;
|
|
|
|
/// Prefer the provided name, falling back to the domain.
|
|
String get displayName => name.isNotEmpty ? name : domain;
|
|
|
|
/// Parse a [MeshInstance] from an API payload entry.
|
|
factory MeshInstance.fromJson(Map<String, dynamic> json) {
|
|
final domain = json['domain']?.toString().trim() ?? '';
|
|
final name = json['name']?.toString().trim() ?? '';
|
|
final id = json['id']?.toString().trim() ?? '';
|
|
final isPrivateRaw = json['isPrivate'] ?? json['private'];
|
|
final isPrivate = isPrivateRaw is bool
|
|
? isPrivateRaw
|
|
: isPrivateRaw?.toString().toLowerCase() == 'true';
|
|
DateTime? lastUpdate;
|
|
final lastUpdateRaw = json['lastUpdateTime'];
|
|
if (lastUpdateRaw != null) {
|
|
final seconds = int.tryParse(lastUpdateRaw.toString());
|
|
if (seconds != null) {
|
|
lastUpdate =
|
|
DateTime.fromMillisecondsSinceEpoch(seconds * 1000, isUtc: true);
|
|
}
|
|
}
|
|
|
|
return MeshInstance(
|
|
name: name,
|
|
domain: domain,
|
|
id: id,
|
|
isPrivate: isPrivate,
|
|
lastUpdateTime: lastUpdate,
|
|
);
|
|
}
|
|
|
|
/// Serialize the instance for persistence.
|
|
Map<String, dynamic> toJson() {
|
|
return {
|
|
'id': id,
|
|
'name': name,
|
|
'domain': domain,
|
|
'isPrivate': isPrivate,
|
|
'lastUpdateTime': lastUpdateTime != null
|
|
? lastUpdateTime!.millisecondsSinceEpoch ~/ 1000
|
|
: null,
|
|
};
|
|
}
|
|
}
|
|
|
|
/// Node metadata persisted locally to avoid repeated network lookups.
|
|
class MeshNode {
|
|
const MeshNode({
|
|
required this.nodeId,
|
|
this.shortName = '',
|
|
this.longName = '',
|
|
this.lastHeard,
|
|
this.firstHeard,
|
|
this.hwModel,
|
|
this.latitude,
|
|
this.longitude,
|
|
});
|
|
|
|
final String nodeId;
|
|
final String shortName;
|
|
final String longName;
|
|
final DateTime? lastHeard;
|
|
final DateTime? firstHeard;
|
|
final String? hwModel;
|
|
final double? latitude;
|
|
final double? longitude;
|
|
|
|
/// Returns a name suitable for chat rendering.
|
|
String get displayShortName => shortName.isNotEmpty
|
|
? shortName
|
|
: NodeShortNameCache.fallbackShortName(nodeId);
|
|
|
|
/// Whether the node was heard within the provided freshness window.
|
|
bool isActive(Duration freshness, DateTime nowUtc) {
|
|
if (lastHeard == null) return false;
|
|
final threshold = nowUtc.subtract(freshness);
|
|
return lastHeard!.isAfter(threshold);
|
|
}
|
|
|
|
factory MeshNode.fromJson(Map<String, dynamic> json) {
|
|
DateTime? parseSeconds(dynamic value) {
|
|
if (value == null) return null;
|
|
final seconds = int.tryParse(value.toString());
|
|
if (seconds == null) return null;
|
|
return DateTime.fromMillisecondsSinceEpoch(seconds * 1000, isUtc: true);
|
|
}
|
|
|
|
double? parseDouble(dynamic value) {
|
|
if (value == null) return null;
|
|
if (value is num) return value.toDouble();
|
|
return double.tryParse(value.toString());
|
|
}
|
|
|
|
return MeshNode(
|
|
nodeId: json['node_id']?.toString() ?? json['id']?.toString() ?? '',
|
|
shortName:
|
|
json['short_name']?.toString() ?? json['shortName']?.toString() ?? '',
|
|
longName:
|
|
json['long_name']?.toString() ?? json['longName']?.toString() ?? '',
|
|
lastHeard:
|
|
parseSeconds(json['last_heard']) ?? parseSeconds(json['lastSeen']),
|
|
firstHeard: parseSeconds(json['first_heard']),
|
|
hwModel: json['hw_model']?.toString(),
|
|
latitude: parseDouble(json['latitude']),
|
|
longitude: parseDouble(json['longitude']),
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() {
|
|
return {
|
|
'node_id': nodeId,
|
|
'short_name': shortName,
|
|
'long_name': longName,
|
|
'last_heard':
|
|
lastHeard != null ? lastHeard!.millisecondsSinceEpoch ~/ 1000 : null,
|
|
'first_heard': firstHeard != null
|
|
? firstHeard!.millisecondsSinceEpoch ~/ 1000
|
|
: null,
|
|
'hw_model': hwModel,
|
|
'latitude': latitude,
|
|
'longitude': longitude,
|
|
};
|
|
}
|
|
}
|
|
|
|
/// Build a messages API URI for a given domain or absolute URL.
|
|
Uri _buildMessagesUri(String domain, {int limit = 1000}) {
|
|
final trimmed = domain.trim();
|
|
final params = {
|
|
'limit': limit.toString(),
|
|
'encrypted': 'false',
|
|
};
|
|
if (trimmed.isEmpty) {
|
|
return Uri.https('potatomesh.net', '/api/messages', params);
|
|
}
|
|
|
|
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
|
final parsed = Uri.parse(trimmed);
|
|
return parsed.replace(
|
|
path: '/api/messages',
|
|
queryParameters: params,
|
|
);
|
|
}
|
|
|
|
return Uri.https(trimmed, '/api/messages', params);
|
|
}
|
|
|
|
/// 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 the bulk nodes API URI for fetching recent nodes.
|
|
Uri _buildNodesUri(String domain, {int limit = 1000}) {
|
|
final trimmedDomain = domain.trim();
|
|
final params = {'limit': limit.toString()};
|
|
|
|
if (trimmedDomain.isEmpty) {
|
|
return Uri.https('potatomesh.net', '/api/nodes', params);
|
|
}
|
|
|
|
if (trimmedDomain.startsWith('http://') ||
|
|
trimmedDomain.startsWith('https://')) {
|
|
final parsed = Uri.parse(trimmedDomain);
|
|
return parsed.replace(path: '/api/nodes', queryParameters: params);
|
|
}
|
|
|
|
return Uri.https(trimmedDomain, '/api/nodes', params);
|
|
}
|
|
|
|
/// 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');
|
|
}
|
|
|
|
/// Build an instances API URI for federation discovery.
|
|
Uri _buildInstancesUri(String domain) {
|
|
final trimmed = domain.trim();
|
|
if (trimmed.isEmpty || trimmed == 'potatomesh.net') {
|
|
return Uri.https('potatomesh.net', '/api/instances');
|
|
}
|
|
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
|
final parsed = Uri.parse(trimmed);
|
|
return parsed.replace(path: '/api/instances');
|
|
}
|
|
return Uri.https(trimmed, '/api/instances');
|
|
}
|
|
|
|
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
|
|
/// [http.Client] is created and closed after the request completes.
|
|
Future<List<MeshMessage>> fetchMessages({
|
|
http.Client? client,
|
|
String domain = 'potatomesh.net',
|
|
int limit = 1000,
|
|
}) async {
|
|
final uri = _buildMessagesUri(domain, limit: limit);
|
|
_logHttp('GET $uri');
|
|
|
|
final httpClient = client ?? http.Client();
|
|
final shouldClose = client == null;
|
|
|
|
final resp = await httpClient.get(uri).timeout(_requestTimeout);
|
|
_logHttp('HTTP ${resp.statusCode} $uri');
|
|
if (shouldClose) {
|
|
httpClient.close();
|
|
}
|
|
if (resp.statusCode != 200) {
|
|
throw Exception('HTTP ${resp.statusCode}: ${resp.body}');
|
|
}
|
|
|
|
final decoded = await _decodeJsonList(resp.body);
|
|
final msgs = decoded.map(MeshMessage.fromJson).toList();
|
|
|
|
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._();
|
|
|
|
MeshNodeResolver? _resolver;
|
|
final Map<String, Future<String>> _cache = {};
|
|
final Map<String, Map<String, String>> _primedShortNames = {};
|
|
bool _allowRemoteLookups = true;
|
|
|
|
/// Registers a resolver that can supply locally cached node metadata.
|
|
void registerResolver(MeshNodeResolver resolver) {
|
|
_resolver = resolver;
|
|
}
|
|
|
|
/// Clears memoised entries; primarily used in tests.
|
|
void clear() {
|
|
_cache.clear();
|
|
_primedShortNames.clear();
|
|
}
|
|
|
|
/// Enables or disables remote lookups for short names.
|
|
set allowRemoteLookups(bool enabled) {
|
|
_allowRemoteLookups = enabled;
|
|
}
|
|
|
|
/// Seeds the cache with a batch of node metadata to avoid network calls.
|
|
void prime({required String domain, required Iterable<MeshNode> nodes}) {
|
|
final key = _normalizeDomainKey(domain);
|
|
final map = _primedShortNames.putIfAbsent(key, () => {});
|
|
for (final node in nodes) {
|
|
final id = node.nodeId.trim();
|
|
final name = node.shortName.trim();
|
|
if (id.isEmpty || name.isEmpty) continue;
|
|
map[id] = name;
|
|
}
|
|
}
|
|
|
|
/// 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 normalizedDomain = _normalizeDomainKey(domain);
|
|
final normalizedId = _normalizeNodeId(trimmedId);
|
|
final fallback = fallbackShortName(trimmedId);
|
|
if (normalizedId.isEmpty) return Future.value(fallback);
|
|
if (!_allowRemoteLookups) return Future.value(fallback);
|
|
|
|
final domainKey = normalizedDomain;
|
|
final primed = _primedShortNames[domainKey];
|
|
if (primed != null) {
|
|
final primedName =
|
|
primed[trimmedId] ?? primed['!$trimmedId'] ?? primed[normalizedId];
|
|
if (primedName != null && primedName.isNotEmpty) {
|
|
return Future.value(padToWidth(primedName));
|
|
}
|
|
}
|
|
|
|
final resolved = _resolver?.findNode(domainKey, trimmedId);
|
|
if (resolved != null && resolved.displayShortName.isNotEmpty) {
|
|
final name = resolved.displayShortName;
|
|
_storePrimed(domainKey, trimmedId, name);
|
|
return Future.value(padToWidth(name));
|
|
}
|
|
|
|
final key = '$domainKey|$normalizedId';
|
|
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 normalizedDomain = _normalizeDomainKey(domain);
|
|
final normalizedId = _normalizeNodeId(nodeId);
|
|
final cacheKey = '$normalizedDomain|$normalizedId';
|
|
if (_cache.containsKey(cacheKey)) {
|
|
return _cache[cacheKey]!;
|
|
}
|
|
|
|
final uri = _buildNodeUri(domain, nodeId);
|
|
final httpClient = client ?? http.Client();
|
|
final shouldClose = client == null;
|
|
|
|
try {
|
|
_logHttp('GET $uri');
|
|
final resp = await httpClient.get(uri).timeout(_requestTimeout);
|
|
_logHttp('HTTP ${resp.statusCode} $uri');
|
|
if (resp.statusCode != 200) return fallback;
|
|
|
|
final decoded = await _decodeJsonMap(resp.body);
|
|
final raw = decoded['short_name'] ?? decoded['shortName'];
|
|
if (raw != null) {
|
|
final name = raw.toString().trim();
|
|
if (name.isNotEmpty) {
|
|
_storePrimed(domain, nodeId, name);
|
|
return padToWidth(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);
|
|
}
|
|
|
|
void _storePrimed(String domain, String nodeId, String name) {
|
|
final domainKey = _normalizeDomainKey(domain);
|
|
final map = _primedShortNames.putIfAbsent(domainKey, () => {});
|
|
final trimmedId = nodeId.trim();
|
|
final normalizedId = _normalizeNodeId(trimmedId);
|
|
map[trimmedId] = name;
|
|
map['!$normalizedId'] = name;
|
|
map[normalizedId] = name;
|
|
}
|
|
|
|
String _normalizeDomainKey(String domain) {
|
|
var cleaned = domain.trim();
|
|
if (cleaned.startsWith('https://')) cleaned = cleaned.substring(8);
|
|
if (cleaned.startsWith('http://')) cleaned = cleaned.substring(7);
|
|
if (cleaned.endsWith('/')) {
|
|
cleaned = cleaned.substring(0, cleaned.length - 1);
|
|
}
|
|
if (cleaned.isEmpty) return 'potatomesh.net';
|
|
return cleaned.toLowerCase();
|
|
}
|
|
|
|
String _normalizeNodeId(String id) {
|
|
final trimmed = id.trim();
|
|
return trimmed.startsWith('!') ? trimmed.substring(1) : trimmed;
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
_logHttp('GET $uri');
|
|
final resp = await httpClient.get(uri).timeout(_requestTimeout);
|
|
_logHttp('HTTP ${resp.statusCode} $uri');
|
|
if (resp.statusCode != 200) return null;
|
|
final decoded = await _decodeJsonMap(resp.body);
|
|
return InstanceVersion.fromJson(decoded);
|
|
} catch (_) {
|
|
return null;
|
|
} finally {
|
|
if (shouldClose) {
|
|
httpClient.close();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fetches and validates federation instances, persisting them locally.
|
|
Future<List<MeshInstance>> fetchInstances({http.Client? client}) {
|
|
final repository = MeshRepository(client: client);
|
|
return repository.discoverInstances(client: client);
|
|
}
|
|
|
|
/// Returns a new list sorted by receive time so older messages render first.
|
|
///
|
|
/// Messages that lack a receive time keep their original positions to avoid
|
|
/// shuffling "unknown" entries to the start or end of the feed. Only messages
|
|
/// with a concrete [rxTime] are re-ordered chronologically.
|
|
List<MeshMessage> sortMessagesByRxTime(List<MeshMessage> messages) {
|
|
final knownTimes = messages.where((m) => m.rxTime != null).toList()
|
|
..sort((a, b) => a.rxTime!.compareTo(b.rxTime!));
|
|
|
|
var knownIndex = 0;
|
|
return messages.map((message) {
|
|
if (message.rxTime == null) {
|
|
return message;
|
|
}
|
|
|
|
final sortedMessage = knownTimes[knownIndex];
|
|
knownIndex += 1;
|
|
return sortedMessage;
|
|
}).toList();
|
|
}
|