feat(block2): social settings + offers cubit factory (area, relays)

Persistence + wiring the offers screen needs, per the product decisions:
- SocialSettings (keystore-backed, no plaintext): coarse area geohash +
  configurable relay URLs, both empty by default (no bundled public relays —
  don't leak metadata to third parties). Registered in DI.
- createOffersCubit(): opens a SocialSession on the first configured relay when
  online, else a null-transport cubit that degrades gracefully. OffersCubit now
  closes the owning session on dispose.

4 settings tests green; offers/social suites still green.
This commit is contained in:
vjrj 2026-07-10 02:54:06 +02:00
parent 7fd87e6e62
commit 528b499209
4 changed files with 116 additions and 1 deletions

View file

@ -25,6 +25,7 @@ import '../services/onboarding_store.dart';
import '../services/recovery_sheet_service.dart'; import '../services/recovery_sheet_service.dart';
import '../services/share_catalog_service.dart'; import '../services/share_catalog_service.dart';
import '../services/social_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 /// 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 /// repositories from here (or via BlocProvider), never by reaching into it deep
@ -83,6 +84,7 @@ Future<void> configureDependencies() async {
..registerSingleton<LabelTextExtractor>(labelExtractor) ..registerSingleton<LabelTextExtractor>(labelExtractor)
..registerSingleton<OnboardingStore>(OnboardingStore(secretStore)) ..registerSingleton<OnboardingStore>(OnboardingStore(secretStore))
..registerSingleton<SocialService>(socialService) ..registerSingleton<SocialService>(socialService)
..registerSingleton<SocialSettings>(SocialSettings(secretStore))
..registerSingleton<ExportImportService>( ..registerSingleton<ExportImportService>(
ExportImportService( ExportImportService(
repository: varietyRepository, repository: varietyRepository,

View file

@ -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<String> areaGeohash() async => (await _store.read(_areaKey)) ?? '';
Future<void> 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<List<String>> 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<void> setRelayUrls(List<String> 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<bool> get isConfigured async =>
(await relayUrls()).isNotEmpty && (await areaGeohash()).isNotEmpty;
}

View file

@ -4,6 +4,9 @@ import 'package:commons_core/commons_core.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.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 /// State of the offer discovery/publish screen. Transport-agnostic it holds
/// only what the UI shows, never a relay handle. /// only what the UI shows, never a relay handle.
class OffersState extends Equatable { class OffersState extends Equatable {
@ -60,9 +63,15 @@ class OffersState extends Equatable {
/// UI stays offline-tolerant (a null transport = the social layer is unavailable /// UI stays offline-tolerant (a null transport = the social layer is unavailable
/// and the screen degrades gracefully). /// and the screen degrades gracefully).
class OffersCubit extends Cubit<OffersState> { class OffersCubit extends Cubit<OffersState> {
OffersCubit(this._transport) : super(const OffersState()); OffersCubit(this._transport, {Future<void> Function()? onDispose})
: _onDispose = onDispose,
super(const OffersState());
final OfferTransport? _transport; 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<void> Function()? _onDispose;
StreamSubscription<Offer>? _sub; StreamSubscription<Offer>? _sub;
/// Whether a live transport is available (relay configured and reachable). /// Whether a live transport is available (relay configured and reachable).
@ -113,6 +122,24 @@ class OffersCubit extends Cubit<OffersState> {
@override @override
Future<void> close() async { Future<void> close() async {
await _sub?.cancel(); await _sub?.cancel();
await _onDispose?.call();
return super.close(); 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<OffersCubit> 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
}
}

View file

@ -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<String, String> _data = {};
@override
Future<String?> read(String key) async => _data[key];
@override
Future<void> 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);
});
}