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:
parent
a59adcca6b
commit
801a846d6e
4 changed files with 116 additions and 1 deletions
|
|
@ -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<void> configureDependencies() async {
|
|||
..registerSingleton<LabelTextExtractor>(labelExtractor)
|
||||
..registerSingleton<OnboardingStore>(OnboardingStore(secretStore))
|
||||
..registerSingleton<SocialService>(socialService)
|
||||
..registerSingleton<SocialSettings>(SocialSettings(secretStore))
|
||||
..registerSingleton<ExportImportService>(
|
||||
ExportImportService(
|
||||
repository: varietyRepository,
|
||||
|
|
|
|||
41
apps/app_seeds/lib/services/social_settings.dart
Normal file
41
apps/app_seeds/lib/services/social_settings.dart
Normal 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;
|
||||
}
|
||||
|
|
@ -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<OffersState> {
|
||||
OffersCubit(this._transport) : super(const OffersState());
|
||||
OffersCubit(this._transport, {Future<void> 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<void> Function()? _onDispose;
|
||||
StreamSubscription<Offer>? _sub;
|
||||
|
||||
/// Whether a live transport is available (relay configured and reachable).
|
||||
|
|
@ -113,6 +122,24 @@ class OffersCubit extends Cubit<OffersState> {
|
|||
@override
|
||||
Future<void> 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<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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
45
apps/app_seeds/test/services/social_settings_test.dart
Normal file
45
apps/app_seeds/test/services/social_settings_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue