feat(market): one-time community-rules gate before joining the market, publishing or messaging
This commit is contained in:
parent
a7e10eba7a
commit
5d25f65f76
7 changed files with 311 additions and 1 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
138
apps/app_seeds/lib/ui/market_gate.dart
Normal file
138
apps/app_seeds/lib/ui/market_gate.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue