feat(market): one-time community-rules gate before joining the market, publishing or messaging

This commit is contained in:
vjrj 2026-07-13 00:52:39 +02:00
parent a7e10eba7a
commit 5d25f65f76
7 changed files with 311 additions and 1 deletions

View file

@ -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(

View file

@ -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<bool> introSeen() async => await _store.read(_introSeenKey) == '1';
/// Records that the intro has been shown; subsequent launches skip it.
Future<void> 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<bool> marketRulesAccepted() async =>
await _store.read(_marketRulesKey) == '1';
/// Records the one-time acceptance of the community rules.
Future<void> markMarketRulesAccepted() => _store.write(_marketRulesKey, '1');
}

View file

@ -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<ChatScreen> createState() => _ChatScreenState();
}
@ -186,6 +194,13 @@ class _ChatScreenState extends State<ChatScreen> {
Future<void> _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)) {

View file

@ -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<bool> ensureMarketRulesAccepted(
BuildContext context,
OnboardingStore store,
) async {
if (await store.marketRulesAccepted()) return true;
if (!context.mounted) return false;
final accepted = await showModalBottomSheet<bool>(
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,
),
),
),
],
),
);
}
}

View file

@ -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<MarketScreen> {
@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<void> _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<void> _init() async {
@ -111,6 +136,13 @@ class _MarketScreenState extends State<MarketScreen> {
/// 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<void> _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<VarietyRepository>();