You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
meshcore-client/lib/main.dart

372 lines
14 KiB

import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'l10n/app_localizations.dart';
import 'package:provider/provider.dart';
import 'screens/chrome_required_screen.dart';
import 'utils/platform_info.dart';
import 'connector/meshcore_connector.dart';
import 'screens/scanner_screen.dart';
import 'services/storage_service.dart';
import 'services/message_retry_service.dart';
import 'services/path_history_service.dart';
import 'services/mesh_topology_service.dart';
import 'services/app_settings_service.dart';
import 'services/notification_service.dart';
import 'services/ble_debug_log_service.dart';
import 'services/app_debug_log_service.dart';
import 'services/file_log_service.dart';
import 'services/background_service.dart';
import 'services/map_tile_cache_service.dart';
import 'services/chat_text_scale_service.dart';
import 'services/translation_service.dart';
import 'services/ui_view_state_service.dart';
import 'services/timeout_prediction_service.dart';
import 'services/observer_config_service.dart';
import 'services/block_service.dart';
import 'services/window_geometry_service.dart';
import 'services/store_consolidation_service.dart';
import 'services/storage_health_service.dart';
import 'services/radio_preset_service.dart';
import 'storage/drift/blob_store.dart';
import 'widgets/contact_sync_shortfall_banner.dart';
import 'widgets/storage_unavailable_banner.dart';
import 'storage/prefs_manager.dart';
import 'utils/app_logger.dart';
import 'widgets/keep_screen_awake.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize SharedPreferences cache
await PrefsManager.initialize();
// Probe the storage layer up front (#385). If the database can't open, e.g.
// the native sqlite library fails to load, every read/write silently fails
// and the app looks wiped. Capture that here so the UI can warn loudly
// instead of showing an empty, normal-looking screen (SAFELANE §6).
final storageHealth = StorageHealthService();
try {
if (!await BlobStore.instance.verifyReadWrite()) {
storageHealth.markUnavailable('database read-back mismatch');
}
} catch (e) {
storageHealth.markUnavailable(e);
// appLogger is not initialized this early; debugPrint is safe here and the
// per-store operations still log via appLogger once it is up.
debugPrint('[Storage] Health probe failed; storage unavailable: $e');
}
// Move bulk data (message history, contacts) out of SharedPreferences into
// drift (#335). Must run after prefs are up and BEFORE any store reads, so
// no code sees a half-migrated state. Idempotent: a no-op once done.
//
// Deliberately awaited: the alternative is stores racing the migration, and
// this is the failure mode that produced #333.
await BlobStore.instance.migrateFromPrefs();
// Consolidate message stores left in other locations by differently-built
// copies (#367): union their messages into the pinned store so nothing shows
// as a gap. One-time, native-only, and before any store is read into memory.
await StoreConsolidationService.run();
// Start always-on file logging (#97); no-op on web.
await FileLogService.instance.init();
// Restore the desktop window's saved position/size (#349); no-op off desktop.
await WindowGeometryService.instance.initialize();
// Initialize services
final storage = StorageService();
final connector = MeshCoreConnector();
final pathHistoryService = PathHistoryService(storage);
final meshTopologyService = MeshTopologyService();
final retryService = MessageRetryService();
final appSettingsService = AppSettingsService();
final bleDebugLogService = BleDebugLogService();
final appDebugLogService = AppDebugLogService();
final backgroundService = BackgroundService();
final mapTileCacheService = MapTileCacheService();
final chatTextScaleService = ChatTextScaleService();
final translationService = TranslationService(appSettingsService);
final uiViewStateService = UiViewStateService();
final timeoutPredictionService = TimeoutPredictionService(storage);
final blockService = BlockService();
// Load settings
await appSettingsService.loadSettings();
// Initialize app logger
appLogger.initialize(
appDebugLogService,
enabled: appSettingsService.settings.appDebugLogEnabled,
);
// Initialize notification service
final notificationService = NotificationService();
await notificationService.initialize();
await backgroundService.initialize();
backgroundService.setLanguageOverrideProvider(
() => appSettingsService.settings.languageOverride,
);
_registerThirdPartyLicenses();
await chatTextScaleService.initialize();
await translationService.refreshDownloadedModels();
await uiViewStateService.initialize();
await timeoutPredictionService.initialize();
await blockService.load();
// Regional radio presets (#728): the last good copy, or the one bundled with
// the app. The picker refreshes it from config-profiles when opened.
final radioPresetService = RadioPresetService();
await radioPresetService.load();
// Wire up connector with services
connector.initialize(
retryService: retryService,
pathHistoryService: pathHistoryService,
topologyService: meshTopologyService,
appSettingsService: appSettingsService,
translationService: translationService,
bleDebugLogService: bleDebugLogService,
appDebugLogService: appDebugLogService,
backgroundService: backgroundService,
timeoutPredictionService: timeoutPredictionService,
blockService: blockService,
);
await connector.loadContactCache();
await connector.loadChannelSettings();
await connector.loadCachedChannels();
// Load persisted channel messages
await connector.loadAllChannelMessages();
await connector.loadUnreadState();
runApp(
MeshCoreApp(
storageHealth: storageHealth,
connector: connector,
retryService: retryService,
pathHistoryService: pathHistoryService,
meshTopologyService: meshTopologyService,
storage: storage,
appSettingsService: appSettingsService,
bleDebugLogService: bleDebugLogService,
appDebugLogService: appDebugLogService,
mapTileCacheService: mapTileCacheService,
chatTextScaleService: chatTextScaleService,
translationService: translationService,
uiViewStateService: uiViewStateService,
timeoutPredictionService: timeoutPredictionService,
blockService: blockService,
radioPresetService: radioPresetService,
),
);
}
void _registerThirdPartyLicenses() {
LicenseRegistry.addLicense(() async* {
yield const LicenseEntryWithLineBreaks(
<String>['Open-Meteo Elevation API Data'],
'''
Data used by LOS elevation lookups is provided by Open-Meteo.
Open-Meteo terms and attribution:
https://open-meteo.com/en/terms
Elevation API:
https://open-meteo.com/en/docs/elevation-api
Attribution license reference:
Creative Commons Attribution 4.0 International (CC BY 4.0)
https://creativecommons.org/licenses/by/4.0/
''',
);
yield const LicenseEntryWithLineBreaks(
<String>['MeshCore suggested radio presets'],
'''
Regional radio presets are MeshCore's suggested settings, maintained by Liam
Cottle for MeshCore and published at https://api.meshcore.nz/api/v1/config.
Offband mirrors them unmodified in OffbandMesh/config-profiles and adds its
own regional presets alongside.
MeshCore: https://github.com/meshcore-dev/MeshCore
Mirror and credit: https://github.com/OffbandMesh/config-profiles#credit
''',
);
});
}
class MeshCoreApp extends StatelessWidget {
final StorageHealthService storageHealth;
final MeshCoreConnector connector;
final MessageRetryService retryService;
final PathHistoryService pathHistoryService;
final MeshTopologyService meshTopologyService;
final StorageService storage;
final AppSettingsService appSettingsService;
final BleDebugLogService bleDebugLogService;
final AppDebugLogService appDebugLogService;
final MapTileCacheService mapTileCacheService;
final ChatTextScaleService chatTextScaleService;
final TranslationService translationService;
final UiViewStateService uiViewStateService;
final TimeoutPredictionService timeoutPredictionService;
final BlockService blockService;
final RadioPresetService radioPresetService;
const MeshCoreApp({
super.key,
required this.storageHealth,
required this.connector,
required this.retryService,
required this.pathHistoryService,
required this.meshTopologyService,
required this.storage,
required this.appSettingsService,
required this.bleDebugLogService,
required this.appDebugLogService,
required this.mapTileCacheService,
required this.chatTextScaleService,
required this.translationService,
required this.uiViewStateService,
required this.timeoutPredictionService,
required this.blockService,
required this.radioPresetService,
});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider.value(value: storageHealth),
ChangeNotifierProvider.value(value: connector),
ChangeNotifierProvider.value(value: retryService),
ChangeNotifierProvider.value(value: pathHistoryService),
ChangeNotifierProvider.value(value: meshTopologyService),
ChangeNotifierProvider.value(value: appSettingsService),
ChangeNotifierProvider.value(value: bleDebugLogService),
ChangeNotifierProvider.value(value: appDebugLogService),
ChangeNotifierProvider.value(value: chatTextScaleService),
ChangeNotifierProvider.value(value: translationService),
ChangeNotifierProvider.value(value: uiViewStateService),
Provider.value(value: storage),
Provider.value(value: mapTileCacheService),
ChangeNotifierProvider.value(value: timeoutPredictionService),
ChangeNotifierProvider.value(value: blockService),
ChangeNotifierProvider.value(value: radioPresetService),
ChangeNotifierProvider(create: (_) => ObserverConfigService(connector)),
],
child: Consumer<AppSettingsService>(
builder: (context, settingsService, child) {
return MaterialApp(
title: 'Offband Meshcore',
debugShowCheckedModeBanner: false,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
locale: _localeFromSetting(
settingsService.settings.languageOverride,
),
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
),
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark,
),
useMaterial3: true,
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
),
),
themeMode: _themeModeFromSetting(
settingsService.settings.themeMode,
),
builder: (context, child) {
// Update notification service with resolved locale
final locale = Localizations.localeOf(context);
NotificationService().setLocale(locale);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: _systemUiOverlayStyle(context),
child: KeepScreenAwake(
child: Consumer<StorageHealthService>(
builder: (context, health, _) => StorageUnavailableBanner(
show: !health.available,
child: Consumer<MeshCoreConnector>(
builder: (context, connector, _) =>
ContactSyncShortfallBanner(
shortfall: connector.contactSyncShortfall,
decisionPending:
connector.contactSyncDecisionPending,
undeliveredCount:
connector.contactSyncUndeliveredCount,
onDismiss: connector.dismissContactSyncShortfall,
onKeep: connector.resolveContactSyncKeepLocal,
onUseRadio: connector.resolveContactSyncUseRadio,
child: child ?? const SizedBox.shrink(),
),
),
),
),
),
);
},
home: (PlatformInfo.isWeb && !PlatformInfo.isChrome)
? const ChromeRequiredScreen()
: const ScannerScreen(),
);
},
),
);
}
ThemeMode _themeModeFromSetting(String value) {
switch (value) {
case 'light':
return ThemeMode.light;
case 'dark':
return ThemeMode.dark;
default:
return ThemeMode.system;
}
}
SystemUiOverlayStyle _systemUiOverlayStyle(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
final iconBrightness = isDark ? Brightness.light : Brightness.dark;
// Keep Android system bars aligned with the resolved Flutter theme.
return SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: iconBrightness,
statusBarBrightness: isDark ? Brightness.dark : Brightness.light,
systemNavigationBarColor: colorScheme.surface,
systemNavigationBarIconBrightness: iconBrightness,
systemNavigationBarDividerColor: colorScheme.surface,
systemNavigationBarContrastEnforced: false,
);
}
Locale? _localeFromSetting(String? languageCode) {
if (languageCode == null) return null;
return Locale(languageCode);
}
}

Powered by TurnKey Linux.