diff --git a/apps/app_seeds/lib/di/injector.dart b/apps/app_seeds/lib/di/injector.dart index 855e86c..37bb62e 100644 --- a/apps/app_seeds/lib/di/injector.dart +++ b/apps/app_seeds/lib/di/injector.dart @@ -25,6 +25,7 @@ import '../services/onboarding_store.dart'; import '../services/recovery_sheet_service.dart'; import '../services/share_catalog_service.dart'; import '../services/social_service.dart'; +import '../services/social_settings.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 @@ -83,6 +84,7 @@ Future configureDependencies() async { ..registerSingleton(labelExtractor) ..registerSingleton(OnboardingStore(secretStore)) ..registerSingleton(socialService) + ..registerSingleton(SocialSettings(secretStore)) ..registerSingleton( ExportImportService( repository: varietyRepository, diff --git a/apps/app_seeds/lib/services/social_settings.dart b/apps/app_seeds/lib/services/social_settings.dart new file mode 100644 index 0000000..c33913b --- /dev/null +++ b/apps/app_seeds/lib/services/social_settings.dart @@ -0,0 +1,41 @@ +import '../security/secret_store.dart'; + +/// User choices for the (Block 2) social layer: the coarse area to publish/ +/// browse offers in, and which community relays to talk to. Backed by the OS +/// keystore (via [SecretStore]) to honour "no plaintext at rest" — no +/// shared_preferences. +/// +/// Both default to "unset": empty area and no relays mean the social layer is +/// simply unavailable, and the app stays fully usable offline. +class SocialSettings { + SocialSettings(this._store); + + final SecretStore _store; + + static const _areaKey = 'tane.social.area_geohash'; + static const _relaysKey = 'tane.social.relays'; + + /// The user's coarse area as a low-precision geohash (may be empty). Set + /// manually or from device location; the transport coarsens it further. + Future areaGeohash() async => (await _store.read(_areaKey)) ?? ''; + + Future setAreaGeohash(String geohash) => + _store.write(_areaKey, geohash.trim().toLowerCase()); + + /// Configured community relay URLs (empty by default — the user adds their + /// own; we bundle none, to avoid leaking metadata to third-party relays). + Future> relayUrls() async { + final raw = await _store.read(_relaysKey); + if (raw == null || raw.isEmpty) return const []; + return raw.split('\n').where((s) => s.trim().isNotEmpty).toList(); + } + + Future setRelayUrls(List urls) => _store.write( + _relaysKey, + urls.map((u) => u.trim()).where((u) => u.isNotEmpty).join('\n'), + ); + + /// Whether the social layer has enough config to attempt going online. + Future get isConfigured async => + (await relayUrls()).isNotEmpty && (await areaGeohash()).isNotEmpty; +} diff --git a/apps/app_seeds/lib/state/offers_cubit.dart b/apps/app_seeds/lib/state/offers_cubit.dart index cfdc29b..edf46cd 100644 --- a/apps/app_seeds/lib/state/offers_cubit.dart +++ b/apps/app_seeds/lib/state/offers_cubit.dart @@ -4,6 +4,9 @@ import 'package:commons_core/commons_core.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import '../services/social_service.dart'; +import '../services/social_settings.dart'; + /// State of the offer discovery/publish screen. Transport-agnostic — it holds /// only what the UI shows, never a relay handle. class OffersState extends Equatable { @@ -60,9 +63,15 @@ class OffersState extends Equatable { /// UI stays offline-tolerant (a null transport = the social layer is unavailable /// and the screen degrades gracefully). class OffersCubit extends Cubit { - OffersCubit(this._transport) : super(const OffersState()); + OffersCubit(this._transport, {Future Function()? onDispose}) + : _onDispose = onDispose, + super(const OffersState()); final OfferTransport? _transport; + + /// Closes the owning [SocialSession]/connection when the cubit is disposed + /// (null in tests, where the transport is a fake with nothing to close). + final Future Function()? _onDispose; StreamSubscription? _sub; /// Whether a live transport is available (relay configured and reachable). @@ -113,6 +122,24 @@ class OffersCubit extends Cubit { @override Future close() async { await _sub?.cancel(); + await _onDispose?.call(); return super.close(); } } + +/// Opens an [OffersCubit] wired to the social layer, or an offline one that +/// degrades gracefully. Local-first: when no relay is configured (or connecting +/// fails), the cubit is created with a null transport and the screen still opens. +Future createOffersCubit( + SocialService social, + SocialSettings settings, +) async { + final relays = await settings.relayUrls(); + if (relays.isEmpty) return OffersCubit(null); + try { + final session = await social.openSession(relays.first); + return OffersCubit(session.offers, onDispose: session.close); + } catch (_) { + return OffersCubit(null); // offline / relay unreachable + } +} diff --git a/apps/app_seeds/test/services/social_settings_test.dart b/apps/app_seeds/test/services/social_settings_test.dart new file mode 100644 index 0000000..c7f1f53 --- /dev/null +++ b/apps/app_seeds/test/services/social_settings_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/security/secret_store.dart'; +import 'package:tane/services/social_settings.dart'; + +/// In-memory [SecretStore] for tests. +class FakeSecretStore implements SecretStore { + final Map _data = {}; + @override + Future read(String key) async => _data[key]; + @override + Future write(String key, String value) async => _data[key] = value; +} + +void main() { + late SocialSettings settings; + setUp(() => settings = SocialSettings(FakeSecretStore())); + + test('defaults: empty area, no relays, not configured', () async { + expect(await settings.areaGeohash(), ''); + expect(await settings.relayUrls(), isEmpty); + expect(await settings.isConfigured, isFalse); + }); + + test('stores and normalises the area geohash (trim + lowercase)', () async { + await settings.setAreaGeohash(' SP3E9 '); + expect(await settings.areaGeohash(), 'sp3e9'); + }); + + test('stores relay urls, dropping blanks', () async { + await settings.setRelayUrls([ + 'wss://relay.comunes.org', + ' ', + 'wss://seeds.example.org', + ]); + expect(await settings.relayUrls(), + ['wss://relay.comunes.org', 'wss://seeds.example.org']); + }); + + test('is configured only with both an area and at least one relay', () async { + await settings.setAreaGeohash('sp3e9'); + expect(await settings.isConfigured, isFalse); + await settings.setRelayUrls(['wss://relay.comunes.org']); + expect(await settings.isConfigured, isTrue); + }); +}