diff --git a/assets/legal/community-guidelines.md b/assets/legal/community-guidelines.md new file mode 100644 index 0000000..1be6801 --- /dev/null +++ b/assets/legal/community-guidelines.md @@ -0,0 +1,164 @@ +# Community Guidelines + +**Last Updated:** February 21, 2026 + +Hey there! Welcome to Coves. We're building a place where people can have conversations, discuss topics, and find communities they care about. These guidelines exist to keep Coves a place where everyone feels welcome. They're pretty straightforward. + +These are the platform-wide rules that apply to the official Coves instance at [coves.social](https://coves.social). Since Coves is open source, other people may run their own instances with their own moderation policies. These guidelines apply to ours. By using our instance, you agree to follow them. + +--- + +## Be Respectful + +**Don't:** +- Target someone with hate speech based on race, ethnicity, gender, sexual orientation, religion, disability, or any other characteristic +- Make threats of violence against other users +- Pile on, dogpile, or coordinate attacks against other users +- Stalk or repeatedly contact someone who has asked you to stop +- Post content that promotes or glorifies animal abuse + +**Do:** +- Use the block button if someone's bothering you + +--- + +## Protect People's Privacy + +What's private should stay private. Don't share other users' personal information without their consent. + +**This includes:** +- Real names, addresses, phone numbers, or email addresses +- Workplace or school information +- Photos or videos of someone taken without their knowledge +- Private messages or conversations shared without permission +- Any information intended to identify or locate another user + +If someone shares something with you in confidence, keep it that way. + +--- + +## No Spam + +Nobody likes spam. + +**Don't:** +- Post the same thing over and over across communities and posts +- Use Coves primarily to promote your product, service, or website +- Use bots or automated tools to post without an authorized aggregator +- Manipulate votes or engagement metrics +- Post misleading links or clickbait + +Sharing something cool you made? That's fine. Making every post an ad? That's spam. + +--- + +## Keep It Legal + +Don't use Coves to break the law. + +**Don't post content that:** +- Violates any applicable laws or regulations +- Promotes or facilitates fraud or scams +- Distributes malware or malicious software +- Facilitates the sale of illegal goods or services + +--- + +## Protect Minors + +This is non-negotiable. We have an absolute zero-tolerance policy when it comes to the safety of minors. + +**The following will result in immediate and permanent removal:** +- Child sexual abuse material (CSAM) of any kind — real, synthetic, AI-generated, or illustrated +- Grooming, solicitation, or predatory behavior toward minors +- Sharing personal information about minors +- Any content that sexualizes or exploits children in any way + +We report all confirmed CSAM to NCMEC and law enforcement. No warnings, no second chances. + +Coves is for users **18 years of age or older**. For full details, see our [Child Safety Policy](https://coves.social/safety/child-safety). + +--- + + +## No Self-Harm Promotion + +Don't post content that promotes, encourages, or provides instructions for self-harm or suicide. If you or someone you know is struggling, please reach out to a crisis service in your area. You can find local resources at [findahelpline.com](https://findahelpline.com/). + +--- + +## Use Content Warnings + +During the alpha period, NSFW content is not permitted on Coves. In future versions, mature content will be supported with proper content warning tags and moderation oversight, allowing users to decide for themselves what they want to see. + +--- + +## What Happens If You Break the Rules + +We use a three-strike system. Each strike is logged against your account: + +1. **Strike 1** — The offending content is removed from the Coves AppView and you receive a one-week suspension +2. **Strike 2** — Content removed and another one-week suspension +3. **Strike 3** — Permanent ban from Coves + +Strikes are cumulative and don't expire. After a permanent ban, you may appeal after one year. + +**Ban evasion:** Creating a new account to get around a ban counts as an immediate Strike 3. Don't do it. + +**Exceptions:** Some violations skip the strike system entirely. Posting CSAM, grooming, or any content that endangers minors results in an immediate permanent ban with no appeal. We report these to NCMEC and law enforcement. + +**A note on content removal:** When content is removed, it's removed from the Coves AppView — meaning it won't appear on Coves for any user. However, because Coves is built on the AT Protocol, the original record may still exist on your Personal Data Server (PDS). We moderate what's visible on our platform, but we don't have the ability to delete data from your PDS. + +--- + +## How to Report + +If you see something that violates these guidelines, report it. Every post and comment has a report option in its menu. When you report, you'll be asked to choose a reason: + +- **Spam** — Unsolicited advertising or repetitive content +- **Harassment** — Bullying, threats, or targeted attacks +- **Privacy violation** — Sharing private information without consent +- **Illegal content** — Content that violates laws or regulations +- **Child safety** — Content exploiting or endangering minors +- **Other** — Anything else that violates these guidelines + +You can also add a short explanation to give us more context. Our team reviews every report. + +--- + +## How to Block + +If someone is bothering you, you don't have to wait for a report to be reviewed. You can block them instantly: + +- Tap the menu on any of their posts or comments +- Select "Block User" +- Their content is immediately removed from your feed + +Blocking is private — the other person won't be notified. + +--- + +## What's Welcome Here + +Coves is a place for open conversation. Journalism, political commentary, educational content, personal stories, and honest debate are all welcome. We moderate behavior, not opinions. You won't be punished for having a controversial take, but you will be held accountable for how you treat other users. + +--- + +## A Note on Coves and the AT Protocol + +Coves is built on the AT Protocol, which means it's part of a larger decentralized network. Your account and identity exist on your Personal Data Server (PDS), not on our servers. These guidelines and our moderation only apply to the official Coves AppView at [coves.social](https://coves.social). Other instances of Coves or other AT Protocol services have their own policies and moderation. If you're banned or have content removed from our AppView, your AT Protocol identity and data on your PDS are unaffected — you can still use other services on the network. + +--- + +## Questions? + +If you have questions about these guidelines or need to reach us for any reason: + +**Email:** support@coves.social +**Website:** [coves.social](https://coves.social) + +--- + +These guidelines may be updated from time to time. We'll do our best to let you know when things change, but it's a good idea to check back occasionally. + +Thanks for being part of Coves! diff --git a/lib/main.dart b/lib/main.dart index ea241a7..a4c92b6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -14,11 +14,13 @@ import 'models/post.dart'; import 'providers/auth_provider.dart'; import 'providers/block_provider.dart'; import 'providers/community_subscription_provider.dart'; +import 'providers/community_guidelines_provider.dart'; import 'providers/eula_provider.dart'; import 'providers/multi_feed_provider.dart'; import 'providers/user_profile_provider.dart'; import 'providers/vote_provider.dart'; import 'screens/auth/login_screen.dart'; +import 'screens/community_guidelines_screen.dart'; import 'screens/eula_screen.dart'; import 'screens/community/community_feed_screen.dart'; import 'screens/home/main_shell_screen.dart'; @@ -75,7 +77,32 @@ Future main() async { // Initialize EULA acceptance provider // Note: initialize() handles errors internally (fail-closed design) final eulaProvider = EulaProvider(); - await eulaProvider.initialize(); + try { + await eulaProvider.initialize(); + } on Exception catch (error, stackTrace) { + await Sentry.captureException( + error, + stackTrace: stackTrace, + withScope: (scope) { + scope.setTag('phase', 'eula_initialization'); + }, + ); + } + + // Initialize community guidelines acceptance provider + // Note: initialize() handles errors internally (fail-closed design) + final communityGuidelinesProvider = CommunityGuidelinesProvider(); + try { + await communityGuidelinesProvider.initialize(); + } on Exception catch (error, stackTrace) { + await Sentry.captureException( + error, + stackTrace: stackTrace, + withScope: (scope) { + scope.setTag('phase', 'community_guidelines_initialization'); + }, + ); + } // Initialize vote service with auth callbacks // Votes go through the Coves backend (which proxies to PDS with DPoP) @@ -100,6 +127,7 @@ Future main() async { providers: [ ChangeNotifierProvider.value(value: authProvider), ChangeNotifierProvider.value(value: eulaProvider), + ChangeNotifierProvider.value(value: communityGuidelinesProvider), ChangeNotifierProvider( create: (_) => VoteProvider( @@ -196,6 +224,8 @@ class CovesApp extends StatelessWidget { Widget build(BuildContext context) { final authProvider = Provider.of(context, listen: false); final eulaProvider = Provider.of(context, listen: false); + final guidelinesProvider = + Provider.of(context, listen: false); return MaterialApp.router( title: 'Coves', @@ -206,7 +236,7 @@ class CovesApp extends StatelessWidget { ), useMaterial3: true, ), - routerConfig: _createRouter(authProvider, eulaProvider), + routerConfig: _createRouter(authProvider, eulaProvider, guidelinesProvider), restorationScopeId: 'app', debugShowCheckedModeBanner: false, ); @@ -214,7 +244,11 @@ class CovesApp extends StatelessWidget { } // GoRouter configuration factory -GoRouter _createRouter(AuthProvider authProvider, EulaProvider eulaProvider) { +GoRouter _createRouter( + AuthProvider authProvider, + EulaProvider eulaProvider, + CommunityGuidelinesProvider guidelinesProvider, +) { return GoRouter( routes: [ GoRoute(path: '/', builder: (context, state) => const LandingScreen()), @@ -226,6 +260,13 @@ GoRouter _createRouter(AuthProvider authProvider, EulaProvider eulaProvider) { return EulaScreen(viewOnly: viewOnly); }, ), + GoRoute( + path: '/community-guidelines', + builder: (context, state) { + final viewOnly = state.uri.queryParameters['viewOnly'] == 'true'; + return CommunityGuidelinesScreen(viewOnly: viewOnly); + }, + ), GoRoute( path: '/feed', builder: (context, state) => const MainShellScreen(), @@ -276,29 +317,48 @@ GoRouter _createRouter(AuthProvider authProvider, EulaProvider eulaProvider) { }, ), ], - refreshListenable: Listenable.merge([authProvider, eulaProvider]), + refreshListenable: Listenable.merge([ + authProvider, + eulaProvider, + guidelinesProvider, + ]), redirect: (context, state) { final isAuthenticated = authProvider.isAuthenticated; final isAuthLoading = authProvider.isLoading; final eulaAccepted = eulaProvider.hasAccepted; final isEulaLoading = eulaProvider.isLoading; + final guidelinesAccepted = guidelinesProvider.hasAccepted; + final isGuidelinesLoading = guidelinesProvider.isLoading; final currentPath = state.uri.path; // Don't redirect while loading initial state - if (isAuthLoading || isEulaLoading) { + if (isAuthLoading || isEulaLoading || isGuidelinesLoading) { return null; } - // EULA must be accepted before accessing any other route + // EULA must be accepted first before anything else if (!eulaAccepted && currentPath != '/eula') { return '/eula'; } - // Prevent navigating to /eula accept mode after already accepting + // Community guidelines must be accepted after EULA if (eulaAccepted && - currentPath == '/eula' && - state.uri.queryParameters['viewOnly'] != 'true') { - return '/'; + !guidelinesAccepted && + currentPath != '/community-guidelines' && + currentPath != '/eula') { + return '/community-guidelines'; + } + + // Prevent navigating to acceptance screens in accept mode after already accepting + final isViewOnly = state.uri.queryParameters['viewOnly'] == 'true'; + if (!isViewOnly) { + if (eulaAccepted && currentPath == '/eula') { + // Go straight to community guidelines if not yet accepted + return guidelinesAccepted ? '/' : '/community-guidelines'; + } + if (guidelinesAccepted && currentPath == '/community-guidelines') { + return '/'; + } } // If authenticated and on landing/login screen, redirect to feed diff --git a/lib/providers/community_guidelines_provider.dart b/lib/providers/community_guidelines_provider.dart new file mode 100644 index 0000000..06305f1 --- /dev/null +++ b/lib/providers/community_guidelines_provider.dart @@ -0,0 +1,59 @@ +import 'package:flutter/foundation.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Tracks whether the user has accepted the current community guidelines version. +/// +/// Uses shared_preferences to persist acceptance state. +/// Increment [currentVersion] when the guidelines change to re-prompt users. +class CommunityGuidelinesProvider with ChangeNotifier { + static const String _acceptedKey = 'community_guidelines_accepted_version'; + static const int currentVersion = 1; + + bool _hasAccepted = false; + bool _isLoading = true; + String? _error; + + bool get hasAccepted => _hasAccepted; + bool get isLoading => _isLoading; + String? get error => _error; + + Future initialize() async { + try { + final prefs = await SharedPreferences.getInstance(); + final acceptedVersion = prefs.getInt(_acceptedKey) ?? 0; + _hasAccepted = acceptedVersion >= currentVersion; + } on Exception catch (e, stackTrace) { + if (kDebugMode) { + print('Failed to check community guidelines acceptance: $e\n$stackTrace'); + } + await Sentry.captureException(e, stackTrace: stackTrace, withScope: (scope) { + scope.setTag('phase', 'community_guidelines_initialization'); + }); + // Fail closed - require acceptance if we can't read state + _hasAccepted = false; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future accept() async { + _error = null; + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_acceptedKey, currentVersion); + _hasAccepted = true; + } on Exception catch (e, stackTrace) { + if (kDebugMode) { + print('Failed to accept community guidelines: $e\n$stackTrace'); + } + await Sentry.captureException(e, stackTrace: stackTrace, withScope: (scope) { + scope.setTag('phase', 'community_guidelines_acceptance'); + }); + _error = 'Failed to save acceptance. Please try again.'; + } finally { + notifyListeners(); + } + } +} diff --git a/lib/providers/eula_provider.dart b/lib/providers/eula_provider.dart index a367a7c..18ecb86 100644 --- a/lib/providers/eula_provider.dart +++ b/lib/providers/eula_provider.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:shared_preferences/shared_preferences.dart'; /// Tracks whether the user has accepted the current EULA version. @@ -26,6 +27,9 @@ class EulaProvider with ChangeNotifier { if (kDebugMode) { print('Failed to check EULA acceptance: $e\n$stackTrace'); } + await Sentry.captureException(e, stackTrace: stackTrace, withScope: (scope) { + scope.setTag('phase', 'eula_initialization'); + }); // Fail closed - require acceptance if we can't read state _hasAccepted = false; } finally { @@ -44,6 +48,9 @@ class EulaProvider with ChangeNotifier { if (kDebugMode) { print('Failed to accept EULA: $e\n$stackTrace'); } + await Sentry.captureException(e, stackTrace: stackTrace, withScope: (scope) { + scope.setTag('phase', 'eula_acceptance'); + }); _error = 'Failed to save EULA acceptance. Please try again.'; } finally { notifyListeners(); diff --git a/lib/screens/community_guidelines_screen.dart b/lib/screens/community_guidelines_screen.dart new file mode 100644 index 0000000..7a94c30 --- /dev/null +++ b/lib/screens/community_guidelines_screen.dart @@ -0,0 +1,510 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../constants/app_colors.dart'; +import '../providers/community_guidelines_provider.dart'; + +class CommunityGuidelinesScreen extends StatefulWidget { + const CommunityGuidelinesScreen({this.viewOnly = false, super.key}); + + final bool viewOnly; + + @override + State createState() => + _CommunityGuidelinesScreenState(); +} + +class _CommunityGuidelinesScreenState extends State { + final ScrollController _scrollController = ScrollController(); + bool _hasScrolledToBottom = false; + bool _hasAgreed = false; + bool _isAccepting = false; + late Future _contentFuture; + + @override + void initState() { + super.initState(); + _contentFuture = + rootBundle.loadString('assets/legal/community-guidelines.md'); + if (!widget.viewOnly) { + _scrollController.addListener(_onScroll); + } + } + + @override + void dispose() { + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_hasScrolledToBottom) return; + final maxScroll = _scrollController.position.maxScrollExtent; + final currentScroll = _scrollController.position.pixels; + if (currentScroll >= maxScroll - 40) { + setState(() => _hasScrolledToBottom = true); + } + } + + Future _handleAccept() async { + setState(() => _isAccepting = true); + final provider = context.read(); + await provider.accept(); + if (!mounted) return; + if (provider.error != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(provider.error!), + backgroundColor: AppColors.error, + ), + ); + } + setState(() => _isAccepting = false); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: widget.viewOnly + ? AppBar( + backgroundColor: AppColors.background, + surfaceTintColor: Colors.transparent, + title: Text( + 'Community Guidelines', + style: GoogleFonts.nunito( + color: AppColors.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + leading: IconButton( + icon: + const Icon(Icons.arrow_back, color: AppColors.textPrimary), + onPressed: () => Navigator.pop(context), + ), + ) + : null, + body: SafeArea( + child: Column( + children: [ + if (!widget.viewOnly) _buildHeader(), + Expanded( + child: _buildContentBody(), + ), + if (!widget.viewOnly) _buildBottomBar(), + ], + ), + ), + ); + } + + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 20), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: AppColors.teal.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.groups_rounded, + color: AppColors.teal, + size: 22, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Community Guidelines', + style: GoogleFonts.nunito( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + 'Please read our community standards', + style: GoogleFonts.nunito( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ); + } + + void _retryLoad() { + setState(() { + _contentFuture = + rootBundle.loadString('assets/legal/community-guidelines.md'); + }); + } + + Future _handleLinkTap(String? href) async { + if (href == null) return; + final uri = Uri.tryParse(href); + if (uri == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Invalid link: $href'), + backgroundColor: AppColors.error, + ), + ); + return; + } + try { + final launched = await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); + if (!launched && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Could not open link: $href'), + backgroundColor: AppColors.error, + ), + ); + } + } on Exception catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Could not open link: $href'), + backgroundColor: AppColors.error, + ), + ); + } + } + + Widget _buildContentBody() { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: AppColors.backgroundSecondary, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.border, width: 1), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: FutureBuilder( + future: _contentFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center( + child: CircularProgressIndicator( + color: AppColors.teal, + strokeWidth: 2, + ), + ); + } + + if (snapshot.hasError) { + debugPrint( + 'Error loading community guidelines: ${snapshot.error}'); + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Error loading guidelines', + style: GoogleFonts.nunito(color: AppColors.error), + ), + const SizedBox(height: 12), + TextButton.icon( + onPressed: _retryLoad, + icon: const Icon(Icons.refresh, size: 18), + label: Text( + 'Retry', + style: GoogleFonts.nunito( + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } + + final content = snapshot.data ?? ''; + if (content.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Guidelines content unavailable', + style: GoogleFonts.nunito(color: AppColors.error), + ), + const SizedBox(height: 12), + TextButton.icon( + onPressed: _retryLoad, + icon: const Icon(Icons.refresh, size: 18), + label: Text( + 'Retry', + style: GoogleFonts.nunito( + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } + return Stack( + children: [ + Markdown( + controller: _scrollController, + data: content, + styleSheet: _buildMarkdownStyle(), + padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), + onTapLink: (text, href, title) => _handleLinkTap(href), + ), + if (!widget.viewOnly && !_hasScrolledToBottom) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: _buildScrollHint(), + ), + ], + ); + }, + ), + ), + ); + } + + Widget _buildScrollHint() { + return Container( + padding: const EdgeInsets.only(bottom: 10, top: 24), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.backgroundSecondary.withValues(alpha: 0.0), + AppColors.backgroundSecondary.withValues(alpha: 0.95), + AppColors.backgroundSecondary, + ], + stops: const [0.0, 0.5, 1.0], + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.keyboard_double_arrow_down_rounded, + color: AppColors.textMuted, + size: 16, + ), + const SizedBox(width: 6), + Text( + 'Scroll to read full guidelines', + style: GoogleFonts.nunito( + fontSize: 12, + color: AppColors.textMuted, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } + + Widget _buildBottomBar() { + final bool canAccept = + _hasScrolledToBottom && _hasAgreed && !_isAccepting; + + return ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 20), + decoration: BoxDecoration( + color: AppColors.background.withValues(alpha: 0.85), + border: const Border( + top: BorderSide(color: AppColors.border, width: 1), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: _hasScrolledToBottom + ? () => setState(() => _hasAgreed = !_hasAgreed) + : null, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + children: [ + _buildCheckbox(), + const SizedBox(width: 12), + Expanded( + child: Text( + 'I have read and agree to the Community Guidelines', + style: GoogleFonts.nunito( + fontSize: 13, + color: _hasScrolledToBottom + ? AppColors.textPrimary + : AppColors.textMuted, + fontWeight: FontWeight.w600, + height: 1.4, + ), + ), + ), + ], + ), + ), + ), + SizedBox( + width: double.infinity, + height: 48, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: canAccept + ? AppColors.coral + : AppColors.coral.withValues(alpha: 0.2), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: canAccept ? _handleAccept : null, + borderRadius: BorderRadius.circular(10), + splashFactory: NoSplash.splashFactory, + highlightColor: Colors.transparent, + child: Center( + child: _isAccepting + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + color: AppColors.background, + strokeWidth: 2, + ), + ) + : Text( + 'Accept & Continue', + style: GoogleFonts.nunito( + fontSize: 15, + fontWeight: FontWeight.w700, + color: canAccept + ? AppColors.background + : AppColors.textMuted, + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildCheckbox() { + final bool enabled = _hasScrolledToBottom; + + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 22, + height: 22, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: _hasAgreed ? AppColors.coral : Colors.transparent, + border: Border.all( + color: _hasAgreed + ? AppColors.coral + : enabled + ? AppColors.textSecondary + : AppColors.textMuted.withValues(alpha: 0.4), + width: 2, + ), + ), + child: _hasAgreed + ? const Icon( + Icons.check_rounded, + size: 16, + color: AppColors.background, + ) + : null, + ); + } + + MarkdownStyleSheet _buildMarkdownStyle() { + return MarkdownStyleSheet( + h1: GoogleFonts.nunito( + fontSize: 20, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + height: 1.3, + ), + h2: GoogleFonts.nunito( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.coral, + height: 1.3, + ), + h3: GoogleFonts.nunito( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + height: 1.4, + ), + p: GoogleFonts.nunito( + fontSize: 13, + color: AppColors.textSecondary, + height: 1.6, + ), + a: GoogleFonts.nunito( + fontSize: 13, + color: AppColors.teal, + decoration: TextDecoration.underline, + ), + listBullet: GoogleFonts.nunito( + fontSize: 13, + color: AppColors.textSecondary, + ), + strong: GoogleFonts.nunito( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + blockSpacing: 10.0, + listIndent: 16.0, + horizontalRuleDecoration: BoxDecoration( + border: Border( + top: BorderSide( + color: AppColors.border, + width: 1, + ), + ), + ), + ); + } +} diff --git a/lib/screens/eula_screen.dart b/lib/screens/eula_screen.dart index d0cc528..08ffc19 100644 --- a/lib/screens/eula_screen.dart +++ b/lib/screens/eula_screen.dart @@ -54,23 +54,18 @@ class _EulaScreenState extends State { Future _handleAccept() async { setState(() => _isAccepting = true); - try { - final eulaProvider = context.read(); - await eulaProvider.acceptEula(); - // Router redirect handles navigation automatically - } on Exception catch (e) { - if (!mounted) return; + final eulaProvider = context.read(); + await eulaProvider.acceptEula(); + if (!mounted) return; + if (eulaProvider.error != null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to accept agreement: $e'), + content: Text(eulaProvider.error!), backgroundColor: AppColors.error, ), ); - } finally { - if (mounted) { - setState(() => _isAccepting = false); - } } + setState(() => _isAccepting = false); } @override @@ -119,12 +114,12 @@ class _EulaScreenState extends State { width: 44, height: 44, decoration: BoxDecoration( - color: AppColors.coral.withValues(alpha: 0.12), + color: AppColors.teal.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(10), ), child: const Icon( Icons.gavel_rounded, - color: AppColors.coral, + color: AppColors.teal, size: 22, ), ), @@ -167,7 +162,6 @@ class _EulaScreenState extends State { if (href == null) return; final uri = Uri.tryParse(href); if (uri == null) { - if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Invalid link: $href'), @@ -176,11 +170,21 @@ class _EulaScreenState extends State { ); return; } - final launched = await launchUrl( - uri, - mode: LaunchMode.externalApplication, - ); - if (!launched && mounted) { + try { + final launched = await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); + if (!launched && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Could not open link: $href'), + backgroundColor: AppColors.error, + ), + ); + } + } on Exception catch (_) { + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Could not open link: $href'), @@ -206,7 +210,7 @@ class _EulaScreenState extends State { if (snapshot.connectionState == ConnectionState.waiting) { return const Center( child: CircularProgressIndicator( - color: AppColors.coral, + color: AppColors.teal, strokeWidth: 2, ), ); @@ -239,11 +243,36 @@ class _EulaScreenState extends State { ); } + final content = snapshot.data ?? ''; + if (content.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Agreement content unavailable', + style: GoogleFonts.nunito(color: AppColors.error), + ), + const SizedBox(height: 12), + TextButton.icon( + onPressed: _retryLoadEula, + icon: const Icon(Icons.refresh, size: 18), + label: Text( + 'Retry', + style: GoogleFonts.nunito( + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } return Stack( children: [ Markdown( controller: _scrollController, - data: snapshot.data ?? '', + data: content, styleSheet: _buildMarkdownStyle(), padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), onTapLink: (text, href, title) => _handleLinkTap(href), diff --git a/lib/screens/home/profile_screen.dart b/lib/screens/home/profile_screen.dart index 59143cd..47ae8d9 100644 --- a/lib/screens/home/profile_screen.dart +++ b/lib/screens/home/profile_screen.dart @@ -133,6 +133,25 @@ class _ProfileScreenState extends State { }, ), const Divider(color: AppColors.border, height: 1), + // View Community Guidelines + ListTile( + leading: const Icon( + Icons.groups_outlined, + color: AppColors.textSecondary, + ), + title: Text( + 'Community Guidelines', + style: GoogleFonts.nunito( + color: AppColors.textPrimary, + fontSize: 16, + ), + ), + onTap: () { + Navigator.pop(sheetContext); + context.push('/community-guidelines?viewOnly=true'); + }, + ), + const Divider(color: AppColors.border, height: 1), // Sign out option ListTile( leading: Icon( diff --git a/pubspec.yaml b/pubspec.yaml index ea3089b..1dddef3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.5+5 +version: 1.0.5+6 environment: sdk: ^3.7.2