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/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,
|
||||||
|
|
|
||||||
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: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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
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