From f5612be301c4d75baa5ebeba9cc1400f57093113 Mon Sep 17 00:00:00 2001 From: Patrick Britton Date: Tue, 17 Feb 2026 03:38:10 -0600 Subject: [PATCH] feat: Phase 5 - Harmony State explainer modal with progression chart, tappable from profile --- .../profile/viewable_profile_screen.dart | 6 +- .../lib/widgets/harmony_explainer_modal.dart | 333 ++++++++++++++++++ 2 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 sojorn_app/lib/widgets/harmony_explainer_modal.dart diff --git a/sojorn_app/lib/screens/profile/viewable_profile_screen.dart b/sojorn_app/lib/screens/profile/viewable_profile_screen.dart index 050c9e4..0f694d5 100644 --- a/sojorn_app/lib/screens/profile/viewable_profile_screen.dart +++ b/sojorn_app/lib/screens/profile/viewable_profile_screen.dart @@ -24,6 +24,7 @@ import '../../services/secure_chat_service.dart'; import '../post/post_detail_screen.dart'; import 'profile_settings_screen.dart'; import 'followers_following_screen.dart'; +import '../../widgets/harmony_explainer_modal.dart'; /// Unified profile screen - handles both own profile and viewing others. /// @@ -1275,7 +1276,9 @@ class _UnifiedProfileScreenState extends ConsumerState } Widget _buildTrustInfo(TrustState trustState) { - return Container( + return GestureDetector( + onTap: () => HarmonyExplainerModal.show(context, trustState), + child: Container( padding: const EdgeInsets.all(AppTheme.spacingMd), decoration: BoxDecoration( color: AppTheme.cardSurface, @@ -1332,6 +1335,7 @@ class _UnifiedProfileScreenState extends ConsumerState ), ], ), + ), ); } diff --git a/sojorn_app/lib/widgets/harmony_explainer_modal.dart b/sojorn_app/lib/widgets/harmony_explainer_modal.dart new file mode 100644 index 0000000..1e2208b --- /dev/null +++ b/sojorn_app/lib/widgets/harmony_explainer_modal.dart @@ -0,0 +1,333 @@ +import 'package:flutter/material.dart'; +import '../models/trust_state.dart'; +import '../models/trust_tier.dart'; +import '../theme/app_theme.dart'; +import '../theme/tokens.dart'; + +/// Modal that explains the Harmony State system. +/// Shows current level, progression chart, and tips. +class HarmonyExplainerModal extends StatelessWidget { + final TrustState trustState; + + const HarmonyExplainerModal({super.key, required this.trustState}); + + static void show(BuildContext context, TrustState trustState) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (_) => HarmonyExplainerModal(trustState: trustState), + ); + } + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.75, + maxChildSize: 0.92, + minChildSize: 0.5, + builder: (_, controller) => Container( + decoration: BoxDecoration( + color: AppTheme.cardSurface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + child: ListView( + controller: controller, + padding: const EdgeInsets.fromLTRB(24, 12, 24, 32), + children: [ + // Handle + Center(child: Container( + width: 40, height: 4, + decoration: BoxDecoration( + color: AppTheme.navyBlue.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(2), + ), + )), + const SizedBox(height: 20), + + // Title + Text('What is Harmony State?', style: TextStyle( + fontSize: 20, fontWeight: FontWeight.w800, color: AppTheme.navyBlue, + )), + const SizedBox(height: 10), + Text( + 'Your Harmony State is your community contribution score. It affects your reach multiplier — how far your posts travel.', + style: TextStyle(fontSize: 14, color: SojornColors.postContentLight, height: 1.5), + ), + const SizedBox(height: 24), + + // Current state card + _CurrentStateCard(trustState: trustState), + const SizedBox(height: 24), + + // Progression chart + Text('Progression', style: TextStyle( + fontSize: 16, fontWeight: FontWeight.w700, color: AppTheme.navyBlue, + )), + const SizedBox(height: 12), + _ProgressionChart(currentTier: trustState.tier), + const SizedBox(height: 24), + + // How to increase + Text('How to Increase Harmony', style: TextStyle( + fontSize: 16, fontWeight: FontWeight.w700, color: AppTheme.navyBlue, + )), + const SizedBox(height: 12), + _TipRow(icon: Icons.check_circle, color: const Color(0xFF4CAF50), + text: 'Post helpful beacons that get upvoted'), + _TipRow(icon: Icons.check_circle, color: const Color(0xFF4CAF50), + text: 'Create posts that receive positive engagement'), + _TipRow(icon: Icons.check_circle, color: const Color(0xFF4CAF50), + text: 'Participate in chains constructively'), + _TipRow(icon: Icons.check_circle, color: const Color(0xFF4CAF50), + text: 'Join and contribute to groups'), + const SizedBox(height: 16), + + // What decreases + Text('What Decreases Harmony', style: TextStyle( + fontSize: 16, fontWeight: FontWeight.w700, color: AppTheme.navyBlue, + )), + const SizedBox(height: 12), + _TipRow(icon: Icons.cancel, color: SojornColors.destructive, + text: 'Spam or inappropriate content'), + _TipRow(icon: Icons.cancel, color: SojornColors.destructive, + text: 'Beacons that get downvoted as false'), + _TipRow(icon: Icons.cancel, color: SojornColors.destructive, + text: 'Repeated community guideline violations'), + ], + ), + ), + ); + } +} + +class _CurrentStateCard extends StatelessWidget { + final TrustState trustState; + const _CurrentStateCard({required this.trustState}); + + @override + Widget build(BuildContext context) { + final tier = trustState.tier; + final score = trustState.harmonyScore; + final multiplier = _multiplierForTier(tier); + final nextTier = _nextTier(tier); + final nextThreshold = _thresholdForTier(nextTier); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + AppTheme.navyBlue.withValues(alpha: 0.06), + AppTheme.brightNavy.withValues(alpha: 0.04), + ], + ), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.1)), + ), + child: Column( + children: [ + Row( + children: [ + Container( + width: 48, height: 48, + decoration: BoxDecoration( + color: _colorForTier(tier).withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Icon(Icons.auto_graph, color: _colorForTier(tier), size: 24), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Current: ${tier.displayName}', style: TextStyle( + fontSize: 16, fontWeight: FontWeight.w700, color: AppTheme.navyBlue, + )), + Text('Score: $score', style: TextStyle( + fontSize: 13, color: SojornColors.textDisabled, + )), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: _colorForTier(tier).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(20), + ), + child: Text('${multiplier}x reach', style: TextStyle( + fontSize: 13, fontWeight: FontWeight.w700, color: _colorForTier(tier), + )), + ), + ], + ), + if (nextTier != null) ...[ + const SizedBox(height: 14), + // Progress bar to next tier + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Next: ${nextTier.displayName}', style: TextStyle( + fontSize: 12, fontWeight: FontWeight.w600, color: SojornColors.textDisabled, + )), + Text('$score / $nextThreshold', style: TextStyle( + fontSize: 12, color: SojornColors.textDisabled, + )), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: (score / nextThreshold).clamp(0.0, 1.0), + backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08), + valueColor: AlwaysStoppedAnimation(_colorForTier(tier)), + minHeight: 6, + ), + ), + ], + ), + ], + ], + ), + ); + } + + String _multiplierForTier(TrustTier tier) { + switch (tier) { + case TrustTier.new_user: return '1.0'; + case TrustTier.established: return '1.5'; + case TrustTier.trusted: return '2.0'; + } + } + + TrustTier? _nextTier(TrustTier tier) { + switch (tier) { + case TrustTier.new_user: return TrustTier.established; + case TrustTier.established: return TrustTier.trusted; + case TrustTier.trusted: return null; + } + } + + int _thresholdForTier(TrustTier? tier) { + switch (tier) { + case TrustTier.established: return 100; + case TrustTier.trusted: return 500; + default: return 100; + } + } + + Color _colorForTier(TrustTier tier) { + switch (tier) { + case TrustTier.new_user: return AppTheme.egyptianBlue; + case TrustTier.established: return AppTheme.royalPurple; + case TrustTier.trusted: return const Color(0xFF4CAF50); + } + } +} + +class _ProgressionChart extends StatelessWidget { + final TrustTier currentTier; + const _ProgressionChart({required this.currentTier}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppTheme.navyBlue.withValues(alpha: 0.03), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.06)), + ), + child: Column( + children: [ + _LevelRow(label: 'New', range: '0–100', multiplier: '1.0x', + color: AppTheme.egyptianBlue, isActive: currentTier == TrustTier.new_user), + const SizedBox(height: 10), + _LevelRow(label: 'Established', range: '100–500', multiplier: '1.5x', + color: AppTheme.royalPurple, isActive: currentTier == TrustTier.established), + const SizedBox(height: 10), + _LevelRow(label: 'Trusted', range: '500+', multiplier: '2.0x', + color: const Color(0xFF4CAF50), isActive: currentTier == TrustTier.trusted), + ], + ), + ); + } +} + +class _LevelRow extends StatelessWidget { + final String label; + final String range; + final String multiplier; + final Color color; + final bool isActive; + + const _LevelRow({ + required this.label, required this.range, + required this.multiplier, required this.color, required this.isActive, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Container( + width: 12, height: 12, + decoration: BoxDecoration( + color: isActive ? color : color.withValues(alpha: 0.2), + shape: BoxShape.circle, + border: isActive ? Border.all(color: color, width: 2) : null, + ), + ), + const SizedBox(width: 12), + Expanded(child: Text(label, style: TextStyle( + fontSize: 14, fontWeight: isActive ? FontWeight.w700 : FontWeight.w500, + color: isActive ? AppTheme.navyBlue : SojornColors.textDisabled, + ))), + Text(range, style: TextStyle(fontSize: 12, color: SojornColors.textDisabled)), + const SizedBox(width: 14), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: isActive ? color.withValues(alpha: 0.12) : AppTheme.navyBlue.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(8), + ), + child: Text(multiplier, style: TextStyle( + fontSize: 12, fontWeight: FontWeight.w700, + color: isActive ? color : SojornColors.textDisabled, + )), + ), + ], + ); + } +} + +class _TipRow extends StatelessWidget { + final IconData icon; + final Color color; + final String text; + + const _TipRow({required this.icon, required this.color, required this.text}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 18, color: color), + const SizedBox(width: 10), + Expanded(child: Text(text, style: TextStyle( + fontSize: 13, color: SojornColors.postContentLight, height: 1.4, + ))), + ], + ), + ); + } +}