import 'dart:async'; import 'dart:io'; import 'package:commons_core/commons_core.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/services.dart' show rootBundle; import 'package:flutter/widgets.dart'; import 'package:get_it/get_it.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import '../data/seed_saving_catalog.dart'; import '../data/species_catalog.dart'; import '../data/species_repository.dart'; import '../data/variety_repository.dart'; import '../db/chat_database.dart'; import '../db/database.dart'; import '../db/encrypted_executor.dart'; import '../i18n/strings.g.dart'; import '../security/secret_store.dart'; import '../security/secure_key_store.dart'; import '../services/auto_backup_service.dart'; import '../services/auto_backup_store.dart'; import '../services/device_id_store.dart'; import '../services/export_import_service.dart'; import '../services/file_picker_file_service.dart'; import '../services/file_service.dart'; import '../services/inbox_service.dart'; import '../services/locale_store.dart'; import '../services/notification_service.dart'; import '../services/ocr/label_text_extractor.dart'; import '../services/ocr/ocr_language.dart'; import '../services/ocr/tesseract_label_extractor.dart'; import '../services/label_sheet_service.dart'; import '../services/onboarding_store.dart'; import '../services/recovery_sheet_service.dart'; import '../services/share_catalog_service.dart'; import '../services/message_store.dart'; import '../services/offer_outbox.dart'; import '../services/plantare_service.dart'; import '../services/profile_cache.dart'; import '../services/profile_store.dart'; import '../services/saved_offers_store.dart'; import '../services/social_account_store.dart'; import '../services/social_connection.dart'; import '../services/social_service.dart'; import '../services/social_settings.dart'; import '../services/sync_service.dart'; import '../services/unread_service.dart'; /// The app's service locator. Kept to the composition root — widgets get their /// repositories from here (or via BlocProvider), never by reaching into it deep /// in the tree. final GetIt getIt = GetIt.instance; /// End-of-init sentinel: registered last, so `isRegistered<_DepsReady>()` means /// the container is fully wired (see [configureDependencies]). Distinct from /// [SocialService], which is optional and absent in inventory-only mode. class _DepsReady { const _DepsReady(); } /// Wires the encrypted DB, keystore and repositories. Call once from `main` /// before `runApp`; the DB key must exist before the DB opens. /// /// Idempotent AND all-or-nothing: a hot restart (or an Android Activity restart /// that keeps the isolate) re-runs `main` while GetIt still holds the singletons. /// /// A dedicated [_DepsReady] marker is registered LAST and used as the "fully /// wired" sentinel, so `isRegistered<_DepsReady>()` means the WHOLE container is /// ready — never a half-built one. If a prior run crashed part-way through init /// (leaving some singletons registered but not the marker), we reset the /// container and rebuild from scratch rather than early-returning on a partial /// container or throwing "already registered" mid-cascade. That partial state is /// safe to reset because `main` aborts before `runApp` on a partial run, so no /// live UI holds these singletons. Without this, the app intermittently fell /// back to the "inventory only" look (market/chat/profile greyed out). Future configureDependencies() async { if (getIt.isRegistered<_DepsReady>()) return; // fully wired already // A prior run half-wired the container (crashed after some registrations but // before the sentinel). Clear it so we rebuild cleanly. if (getIt.isRegistered()) await getIt.reset(); final secretStore = FlutterSecretStore(); final keyStore = SecureKeyStore(store: secretStore); final dbKeyHex = await keyStore.databaseKeyHex(); final rootSeedHex = await keyStore.rootSeedHex(); // Which social identity ("account") of the root seed is active. Namespaces the // per-identity stores (chats, profile, name cache) so identities never mix. final accounts = SocialAccountStore(secretStore); final activeAccount = await accounts.active(); final scope = socialAccountScope(activeAccount); // Stable per-install id for device-to-device sync (distinct from the // seed-derived CRDT node id, which is shared across a user's devices). final deviceId = await DeviceIdStore(secretStore).deviceId(); final database = AppDatabase( openEncryptedExecutor(await _databaseFile(), dbKeyHex), ); // Chat history lives in its own encrypted database (same SQLCipher key), // isolated from the inventory schema/sync. See [Messages]. final chatDatabase = ChatDatabase( openEncryptedExecutor(await _chatDatabaseFile(), dbKeyHex), ); // CRDT author id: a stable per-install slice of the root seed. Kept distinct // from the social-layer public key on purpose — unifying the two would rewrite // authorship of existing rows, a data-model decision for later. final nodeId = rootSeedHex.substring(0, 16); // Block 2 social identity: a secp256k1 Nostr key derived (one-way) from the // SAME root seed, so it needs no extra backup. Cheap + offline; no relay is // contacted here (local-first — the social layer only enriches). Non-fatal: if // the derivation ever fails, the app still opens on inventory (market/chat/ // profile stay hidden) rather than blanking the whole app. SocialService? socialService; try { socialService = await SocialService.fromRootSeedHex( rootSeedHex, account: activeAccount, deviceId: deviceId, ); } catch (e, s) { debugPrint( 'Social identity derivation failed; inventory-only mode: $e\n$s', ); } // Seed the bundled species catalog before the UI opens — but only once per // catalog version. Parsing the ~1.5 MB catalog and scanning the species table // on every launch was the main startup jank; the version marker skips both // when this install already holds the current catalog. const catalogVersionKey = 'species_catalog_version'; final speciesRepository = SpeciesRepository(database, idGen: IdGen()); await speciesRepository.seedBundledIfNeeded( version: speciesCatalogVersion, readVersion: () => secretStore.read(catalogVersionKey), writeVersion: (v) => secretStore.write(catalogVersionKey, v), loadSeeds: loadBundledSpecies, ); // Small read-only reference data — kept in memory, not the DB. Loaded before // the UI so the variety detail can show seed-saving guidance synchronously. await SeedSavingCatalog.ensureLoaded(); final varietyRepository = VarietyRepository( database, idGen: IdGen(), nodeId: nodeId, ); const fileService = FilePickerFileService(); // OCR label suggestions only where a native Tesseract engine exists; every // other platform (desktop, web) degrades to the no-op and hides the button. // The language pack(s) follow the user's locale(s) — never a fixed region — // limited to what we actually bundle, always unioned with the Latin baseline. final LabelTextExtractor labelExtractor = (Platform.isAndroid || Platform.isIOS) ? TesseractLabelExtractor(language: await _resolveOcrLanguage()) : const NoOpLabelTextExtractor(); getIt ..registerSingleton(keyStore) ..registerSingleton(database) ..registerSingleton(chatDatabase) ..registerSingleton(speciesRepository) ..registerSingleton(varietyRepository) ..registerSingleton(fileService) ..registerSingleton(labelExtractor) ..registerSingleton(OnboardingStore(secretStore)) ..registerSingleton(LocaleStore(secretStore)) ..registerSingleton(SocialSettings(secretStore)) ..registerSingleton(accounts) ..registerSingleton(OfferOutbox(secretStore)) // Per-identity stores are namespaced by the active account's scope. ..registerSingleton( MessageStore(chatDatabase, accountScope: scope), ) ..registerSingleton( ProfileStore(secretStore, accountScope: scope), ) ..registerSingleton( ProfileCache(secretStore, accountScope: scope), ) ..registerSingleton( SavedOffersStore(secretStore, accountScope: scope), ) ..registerSingleton( ExportImportService( repository: varietyRepository, files: fileService, keys: keyStore, ), ) ..registerSingleton( ShareCatalogService(files: fileService), ) ..registerSingleton( RecoverySheetService(files: fileService), ) ..registerSingleton( LabelSheetService(files: fileService), ); // Automatic silent backups need real file storage; the web build has none, so // it simply goes without (the manual "save a copy" still works there). if (!kIsWeb) { getIt.registerSingleton( AutoBackupService( exporter: getIt(), store: AutoBackupStore(secretStore), directory: _backupsDir, ), ); } // OS notifications for foreground messages. Registered unconditionally — it // self-no-ops on unsupported platforms (web/Windows) and when the social layer // is absent nothing ever calls it. Its tap handler is wired to the router in // `TaneApp`, after the router exists. getIt.registerSingleton(NotificationService()); // Optional: absent only if the derivation above failed — then the app runs // inventory-only, by design (market/chat/profile hidden). if (socialService != null) { // ONE shared relay connection per identity, reused by every feature. final connection = SocialConnection( social: socialService, settings: getIt(), ); getIt ..registerSingleton(socialService) ..registerSingleton(connection) // Tracks unread private messages so the UI can badge them. ..registerSingleton( UnreadService(getIt(), secretStore), ) // App-wide inbox listener over the shared connection, so private messages // arrive (and land in the inbox list) even when the chat isn't open. // Started in `Bootstrap`; it also updates unread and fires notifications. ..registerSingleton( InboxService( connection: connection, selfPubkey: socialService.publicKeyHex, store: getIt(), profileCache: getIt(), unread: getIt(), notifications: getIt(), settings: getIt(), ), ) // Replicates the inventory across this identity's own devices over the // shared connection. Started in `Bootstrap`. ..registerSingleton( SyncService( connection: connection, io: getIt(), localChanges: database.tableUpdates().map((_) {}), selfDeviceId: deviceId, ), ) // Drives the bilateral signed Plantaré handshake over the shared // connection, reconciling moves into the local ledger. Started in // `Bootstrap`. ..registerSingleton( PlantareService( repo: varietyRepository, selfPubkey: socialService.publicKeyHex, selfSecretKey: socialService.identity.privateKeyHex, connection: connection, ), ); } // Registered LAST: the "fully wired" sentinel the guard above checks. Anything // that throws before here leaves the marker unregistered, so the next run // rebuilds cleanly instead of reusing a half-built container. getIt.registerSingleton<_DepsReady>(const _DepsReady()); } /// Switches the active social identity to [account], re-deriving the Nostr key /// from the SAME root seed and re-scoping the per-identity stores (chats, /// profile, name cache) so nothing leaks between identities. Restarts the inbox /// listener on the new identity. The caller must rebuild the widget tree /// (`RestartWidget.restart`) so screens and router pick up the new singletons. /// The DB, inventory and relay settings are untouched — only the social slice. Future switchSocialAccount(int account) async { final secretStore = FlutterSecretStore(); await getIt().setActive(account); final scope = socialAccountScope(account); final rootSeedHex = await getIt().rootSeedHex(); // Per-install, so it survives an identity switch. final deviceId = await DeviceIdStore(secretStore).deviceId(); // Tear down the old identity's listener + sync + shared connection first. if (getIt.isRegistered()) { await getIt().stop(); await getIt.unregister(); } if (getIt.isRegistered()) { await getIt().stop(); await getIt.unregister(); } if (getIt.isRegistered()) { await getIt().stop(); await getIt.unregister(); } if (getIt.isRegistered()) { await getIt().dispose(); await getIt.unregister(); } if (getIt.isRegistered()) { await getIt.unregister(); } if (getIt.isRegistered()) { await getIt.unregister(); } await getIt.unregister(); await getIt.unregister(); await getIt.unregister(); await getIt().close(); await getIt.unregister(); // Re-register the per-identity stores under the new scope, then the identity. getIt ..registerSingleton( MessageStore(getIt(), accountScope: scope), ) ..registerSingleton( ProfileStore(secretStore, accountScope: scope), ) ..registerSingleton( ProfileCache(secretStore, accountScope: scope), ) ..registerSingleton( SavedOffersStore(secretStore, accountScope: scope), ) ..registerSingleton( UnreadService(getIt(), secretStore), ); final social = await SocialService.fromRootSeedHex( rootSeedHex, account: account, deviceId: deviceId, ); final connection = SocialConnection( social: social, settings: getIt(), ); final inbox = InboxService( connection: connection, selfPubkey: social.publicKeyHex, store: getIt(), profileCache: getIt(), unread: getIt(), notifications: getIt(), settings: getIt(), ); final sync = SyncService( connection: connection, io: getIt(), localChanges: getIt().tableUpdates().map((_) {}), selfDeviceId: deviceId, ); final plantares = PlantareService( repo: getIt(), selfPubkey: social.publicKeyHex, selfSecretKey: social.identity.privateKeyHex, connection: connection, ); getIt ..registerSingleton(social) ..registerSingleton(connection) ..registerSingleton(inbox) ..registerSingleton(sync) ..registerSingleton(plantares); // Subscribe the listeners BEFORE the connection starts connecting. inbox.start(); sync.start(); plantares.start(); connection.start(); } Future _backupsDir() async { final dir = await getApplicationSupportDirectory(); return Directory(p.join(dir.path, 'backups')); } Future _databaseFile() async { final dir = await getApplicationDocumentsDirectory(); return File(p.join(dir.path, 'tane_inventory.sqlite')); } Future _chatDatabaseFile() async { final dir = await getApplicationDocumentsDirectory(); return File(p.join(dir.path, 'tane_chat.sqlite')); } /// Picks the Tesseract language pack(s) for the current locale(s), limited to /// the bundled packs listed in `tessdata_config.json`. Prefers the app's chosen /// language, then the device's ordered locales. Future _resolveOcrLanguage() async { final available = parseAvailableOcrLanguages( await rootBundle.loadString('assets/tessdata_config.json'), ); final preferred = [ LocaleSettings.currentLocale.languageCode, for (final l in WidgetsBinding.instance.platformDispatcher.locales) l.languageCode, ]; return resolveOcrLanguages(preferredLocales: preferred, available: available); }