diff --git a/apps/app_seeds/lib/app.dart b/apps/app_seeds/lib/app.dart index a9d40d5..80a87bc 100644 --- a/apps/app_seeds/lib/app.dart +++ b/apps/app_seeds/lib/app.dart @@ -169,6 +169,7 @@ class TaneApp extends StatelessWidget { connection: connection, location: location, outbox: outbox, + onboarding: onboarding, ), ), if (social != null && connection != null) @@ -236,6 +237,7 @@ class TaneApp extends StatelessWidget { peerPubkey: state.pathParameters['pubkey']!, messageStore: messageStore, profileCache: profileCache, + onboarding: onboarding, ), ), GoRoute( diff --git a/apps/app_seeds/lib/services/onboarding_store.dart b/apps/app_seeds/lib/services/onboarding_store.dart index 47611a0..75c54fb 100644 --- a/apps/app_seeds/lib/services/onboarding_store.dart +++ b/apps/app_seeds/lib/services/onboarding_store.dart @@ -9,10 +9,20 @@ class OnboardingStore { final SecretStore _store; static const _introSeenKey = 'tane.intro_seen'; + static const _marketRulesKey = 'tane.market_rules_accepted'; /// Whether the user has already seen (or skipped) the intro carousel. Future introSeen() async => await _store.read(_introSeenKey) == '1'; /// Records that the intro has been shown; subsequent launches skip it. Future markIntroSeen() => _store.write(_introSeenKey, '1'); + + /// Whether the user has accepted the community rules that gate the market. + /// Required once before joining the market or publishing anything (stores + /// ask for terms acceptance before user content is created). + Future marketRulesAccepted() async => + await _store.read(_marketRulesKey) == '1'; + + /// Records the one-time acceptance of the community rules. + Future markMarketRulesAccepted() => _store.write(_marketRulesKey, '1'); } diff --git a/apps/app_seeds/lib/ui/chat_screen.dart b/apps/app_seeds/lib/ui/chat_screen.dart index d71f209..5cf249a 100644 --- a/apps/app_seeds/lib/ui/chat_screen.dart +++ b/apps/app_seeds/lib/ui/chat_screen.dart @@ -11,6 +11,7 @@ import '../domain/chat_timeline.dart'; import '../domain/message_rules.dart'; import '../i18n/strings.g.dart'; import '../services/message_store.dart'; +import '../services/onboarding_store.dart'; import '../services/profile_cache.dart'; import '../services/profile_store.dart'; import '../services/social_connection.dart'; @@ -19,6 +20,7 @@ import '../services/unread_service.dart'; import '../state/messages_cubit.dart'; import '../state/peer_rating_cubit.dart'; import '../state/trust_cubit.dart'; +import 'market_gate.dart'; import 'peer_avatar.dart'; import 'rating_sheet.dart'; import 'theme.dart'; @@ -33,6 +35,7 @@ class ChatScreen extends StatefulWidget { required this.peerPubkey, this.messageStore, this.profileCache, + this.onboarding, super.key, }); @@ -49,6 +52,11 @@ class ChatScreen extends StatefulWidget { /// Optional cache of peer display names; null in tests. final ProfileCache? profileCache; + /// When set, the first message requires the one-time community-rules + /// acceptance (a chat can be reached from an incoming message without ever + /// entering the market). Null in tests → no gate. + final OnboardingStore? onboarding; + @override State createState() => _ChatScreenState(); } @@ -186,6 +194,13 @@ class _ChatScreenState extends State { Future _send() async { final cubit = _messages; if (cubit == null || _input.text.trim().isEmpty) return; + // Writing to someone is creating content: the community-rules acceptance + // must exist even when this chat was reached without entering the market. + final store = widget.onboarding; + if (store != null) { + final ok = await ensureMarketRulesAccepted(context, store); + if (!ok || !mounted) return; + } final text = _input.text; // Links aren't allowed — warn and keep the text so it can be edited. if (containsUrl(text)) { diff --git a/apps/app_seeds/lib/ui/market_gate.dart b/apps/app_seeds/lib/ui/market_gate.dart new file mode 100644 index 0000000..b43edb1 --- /dev/null +++ b/apps/app_seeds/lib/ui/market_gate.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../i18n/strings.g.dart'; +import '../services/onboarding_store.dart'; +import 'theme.dart'; + +/// Makes sure the community rules have been accepted once before the user +/// joins the market or publishes anything. Returns true when the rules are +/// (or become) accepted; false when the user declines. Play/App Store UGC +/// policies require this acceptance before content can be created. +Future ensureMarketRulesAccepted( + BuildContext context, + OnboardingStore store, +) async { + if (await store.marketRulesAccepted()) return true; + if (!context.mounted) return false; + final accepted = await showModalBottomSheet( + context: context, + isScrollControlled: true, + isDismissible: false, + enableDrag: false, + builder: (_) => const MarketGateSheet(), + ); + if (accepted == true) { + await store.markMarketRulesAccepted(); + return true; + } + return false; +} + +/// The one-time "before you join the market" sheet: the community rules in +/// a few bullets, the publish-is-public note, and a link to the full texts. +class MarketGateSheet extends StatelessWidget { + const MarketGateSheet({super.key}); + + @override + Widget build(BuildContext context) { + final t = context.t; + final theme = Theme.of(context); + return SafeArea( + child: SingleChildScrollView( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 24, + bottom: 16 + MediaQuery.of(context).viewInsets.bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + t.marketGate.title, + style: theme.textTheme.titleLarge?.copyWith( + color: seedOnSurface, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + Text( + t.marketGate.intro, + style: theme.textTheme.bodyMedium?.copyWith( + color: seedOnSurface, + height: 1.4, + ), + ), + const SizedBox(height: 12), + _Rule(t.marketGate.ruleHonest), + _Rule(t.marketGate.ruleLegal), + _Rule(t.marketGate.ruleRespect), + const SizedBox(height: 12), + Text( + t.marketGate.publicNote, + style: theme.textTheme.bodySmall?.copyWith( + color: seedMuted, + height: 1.4, + ), + ), + TextButton.icon( + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + foregroundColor: seedGreen, + ), + icon: const Icon(Icons.privacy_tip_outlined, size: 16), + label: Text(t.marketGate.viewLegal), + onPressed: () => context.push('/legal'), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(t.marketGate.decline), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(t.marketGate.accept), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _Rule extends StatelessWidget { + const _Rule(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsetsDirectional.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.check_circle_outline, size: 18, color: seedGreen), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: seedOnSurface, + height: 1.35, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/market_screen.dart b/apps/app_seeds/lib/ui/market_screen.dart index 3e0ed84..e69c5ea 100644 --- a/apps/app_seeds/lib/ui/market_screen.dart +++ b/apps/app_seeds/lib/ui/market_screen.dart @@ -11,9 +11,11 @@ import '../services/offer_outbox.dart'; import '../services/social_connection.dart'; import '../services/social_service.dart'; import '../services/social_settings.dart'; +import '../services/onboarding_store.dart'; import '../state/offers_cubit.dart'; import 'edge_fade.dart'; import 'filter_chips.dart'; +import 'market_gate.dart'; import 'market_widgets.dart'; import 'theme.dart'; @@ -27,12 +29,18 @@ class MarketScreen extends StatefulWidget { required this.connection, this.location, this.outbox, + this.onboarding, super.key, }); final SocialService social; final SocialSettings settings; + /// When set, joining the market is gated on a one-time acceptance of the + /// community rules (store UGC policies require it before content is + /// created). Null in tests → no gate. + final OnboardingStore? onboarding; + /// The shared relay connection (one per identity), reused for discovery. final SocialConnection connection; @@ -56,7 +64,24 @@ class _MarketScreenState extends State { @override void initState() { super.initState(); - _init(); + _start(); + } + + /// Runs the one-time community-rules gate (when wired) before touching the + /// network; declining leaves the market. + Future _start() async { + final store = widget.onboarding; + if (store != null && !await store.marketRulesAccepted()) { + // Wait for the first frame so the sheet has a surface to attach to. + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + final ok = await ensureMarketRulesAccepted(context, store); + if (!ok) { + if (mounted) context.pop(); + return; + } + } + await _init(); } Future _init() async { @@ -111,6 +136,13 @@ class _MarketScreenState extends State { /// Publishes the user's shared lots as offers to the network, tagged with the /// coarse area. Prompts for setup when the area isn't set. Future _shareMine() async { + // Defense in depth: publishing is what actually creates content, so the + // rules gate is re-checked here even though market entry already ran it. + final store = widget.onboarding; + if (store != null) { + final ok = await ensureMarketRulesAccepted(context, store); + if (!ok || !mounted) return; + } final cubit = _cubit; if (cubit == null) return; final repo = context.read(); diff --git a/apps/app_seeds/test/services/onboarding_store_test.dart b/apps/app_seeds/test/services/onboarding_store_test.dart new file mode 100644 index 0000000..ecbe5fb --- /dev/null +++ b/apps/app_seeds/test/services/onboarding_store_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/onboarding_store.dart'; + +import '../support/test_support.dart'; + +void main() { + test('market rules start unaccepted and stay accepted once marked', () async { + final store = OnboardingStore(InMemorySecretStore()); + + expect(await store.marketRulesAccepted(), isFalse); + await store.markMarketRulesAccepted(); + expect(await store.marketRulesAccepted(), isTrue); + }); + + test('market-rules flag is independent of the intro flag', () async { + final store = OnboardingStore(InMemorySecretStore()); + + await store.markIntroSeen(); + expect(await store.introSeen(), isTrue); + expect(await store.marketRulesAccepted(), isFalse); + }); +} diff --git a/apps/app_seeds/test/ui/market_gate_test.dart b/apps/app_seeds/test/ui/market_gate_test.dart new file mode 100644 index 0000000..7cce096 --- /dev/null +++ b/apps/app_seeds/test/ui/market_gate_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/services/onboarding_store.dart'; +import 'package:tane/ui/market_gate.dart'; + +import '../support/test_support.dart'; + +void main() { + Widget host(OnboardingStore store, void Function(bool) onResult) => + TranslationProvider( + child: MaterialApp( + localizationsDelegates: GlobalMaterialLocalizations.delegates, + home: Builder( + builder: (context) => Center( + child: ElevatedButton( + onPressed: () async { + onResult(await ensureMarketRulesAccepted(context, store)); + }, + child: const Text('enter'), + ), + ), + ), + ), + ); + + testWidgets('accepting persists the flag and returns true', (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final store = OnboardingStore(InMemorySecretStore()); + bool? result; + + await tester.pumpWidget(host(store, (r) => result = r)); + await tester.tap(find.text('enter')); + await tester.pumpAndSettle(); + + expect(find.text('Before you join the market'), findsOneWidget); + await tester.tap(find.text('I agree')); + await tester.pumpAndSettle(); + + expect(result, isTrue); + expect(await store.marketRulesAccepted(), isTrue); + + // Second entry: already accepted, no sheet. + result = null; + await tester.tap(find.text('enter')); + await tester.pumpAndSettle(); + expect(find.text('Before you join the market'), findsNothing); + expect(result, isTrue); + }); + + testWidgets('declining returns false and persists nothing', (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final store = OnboardingStore(InMemorySecretStore()); + bool? result; + + await tester.pumpWidget(host(store, (r) => result = r)); + await tester.tap(find.text('enter')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Not now')); + await tester.pumpAndSettle(); + + expect(result, isFalse); + expect(await store.marketRulesAccepted(), isFalse); + }); + + testWidgets('the sheet shows the three rules and the public note', ( + tester, + ) async { + LocaleSettings.setLocaleSync(AppLocale.en); + await tester.pumpWidget( + TranslationProvider( + child: const MaterialApp( + localizationsDelegates: GlobalMaterialLocalizations.delegates, + home: Scaffold(body: MarketGateSheet()), + ), + ), + ); + await tester.pump(); + + expect(find.text('Be honest about the seeds you offer'), findsOneWidget); + expect( + find.text("Only share what you're allowed to share where you live"), + findsOneWidget, + ); + expect(find.text('Treat people well — no spam, no abuse'), findsOneWidget); + expect(find.textContaining('public'), findsWidgets); + expect(find.text('Privacy & rules'), findsOneWidget); + }); +}