feat: Phase 1.2 - 3-screen onboarding modal (Welcome, Features, Harmony)

This commit is contained in:
Patrick Britton 2026-02-17 03:31:32 -06:00
parent c255386db5
commit 0c183c3491
2 changed files with 401 additions and 0 deletions

View file

@ -14,6 +14,7 @@ import '../beacon/beacon_screen.dart';
import '../quips/create/quip_creation_flow.dart'; import '../quips/create/quip_creation_flow.dart';
import '../secure_chat/secure_chat_full_screen.dart'; import '../secure_chat/secure_chat_full_screen.dart';
import '../../widgets/radial_menu_overlay.dart'; import '../../widgets/radial_menu_overlay.dart';
import '../../widgets/onboarding_modal.dart';
import '../../providers/quip_upload_provider.dart'; import '../../providers/quip_upload_provider.dart';
import '../../providers/notification_provider.dart'; import '../../providers/notification_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -40,6 +41,9 @@ class _HomeShellState extends ConsumerState<HomeShell> with WidgetsBindingObserv
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
_chatService.startBackgroundSync(); _chatService.startBackgroundSync();
_initNotificationListener(); _initNotificationListener();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) OnboardingModal.showIfNeeded(context);
});
} }
void _initNotificationListener() { void _initNotificationListener() {

View file

@ -0,0 +1,397 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../theme/app_theme.dart';
import '../theme/tokens.dart';
/// 3-screen swipeable onboarding modal shown on first app launch.
/// Stores completion in SharedPreferences so it only shows once.
class OnboardingModal extends StatefulWidget {
const OnboardingModal({super.key});
static const _prefKey = 'onboarding_completed';
/// Shows the onboarding modal if the user hasn't completed it yet.
/// Call this from HomeShell.initState via addPostFrameCallback.
static Future<void> showIfNeeded(BuildContext context) async {
final prefs = await SharedPreferences.getInstance();
if (prefs.getBool(_prefKey) == true) return;
if (!context.mounted) return;
showGeneralDialog(
context: context,
barrierDismissible: false,
barrierColor: Colors.black54,
pageBuilder: (_, __, ___) => const OnboardingModal(),
);
}
/// Resets the onboarding flag so it shows again (for Settings "Show Tutorial Again").
static Future<void> reset() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_prefKey);
}
@override
State<OnboardingModal> createState() => _OnboardingModalState();
}
class _OnboardingModalState extends State<OnboardingModal> {
final _controller = PageController();
int _currentPage = 0;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _complete() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(OnboardingModal._prefKey, true);
if (mounted) Navigator.of(context).pop();
}
void _next() {
if (_currentPage < 2) {
_controller.nextPage(duration: const Duration(milliseconds: 300), curve: Curves.easeInOut);
} else {
_complete();
}
}
@override
Widget build(BuildContext context) {
return Center(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 24),
constraints: const BoxConstraints(maxWidth: 400, maxHeight: 520),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Material(
color: Colors.transparent,
child: Column(
children: [
Expanded(
child: PageView(
controller: _controller,
onPageChanged: (i) => setState(() => _currentPage = i),
children: const [
_WelcomePage(),
_FeaturesPage(),
_HarmonyPage(),
],
),
),
// Page indicator + button
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
child: Column(
children: [
// Dots
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(3, (i) => Container(
width: _currentPage == i ? 24 : 8,
height: 8,
margin: const EdgeInsets.symmetric(horizontal: 3),
decoration: BoxDecoration(
color: _currentPage == i
? AppTheme.navyBlue
: AppTheme.navyBlue.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
),
)),
),
const SizedBox(height: 20),
// CTA button
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: _next,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.navyBlue,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
elevation: 0,
),
child: Text(
_currentPage == 2 ? 'Get Started' : 'Next',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
),
),
),
if (_currentPage < 2) ...[
const SizedBox(height: 8),
TextButton(
onPressed: _complete,
child: Text('Skip', style: TextStyle(
color: AppTheme.navyBlue.withValues(alpha: 0.5),
fontSize: 13,
)),
),
],
],
),
),
],
),
),
),
),
);
}
}
// Screen 1: Welcome
class _WelcomePage extends StatelessWidget {
const _WelcomePage();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(28, 40, 28, 8),
child: Column(
children: [
Container(
width: 80, height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [AppTheme.navyBlue, AppTheme.brightNavy],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(24),
),
child: const Icon(Icons.shield_outlined, color: Colors.white, size: 40),
),
const SizedBox(height: 28),
Text('Welcome to Your Sanctuary', style: TextStyle(
fontSize: 22, fontWeight: FontWeight.w800, color: AppTheme.navyBlue,
), textAlign: TextAlign.center),
const SizedBox(height: 14),
Text(
'A private, intentional social space.\nYour posts are encrypted. Your data belongs to you.',
style: TextStyle(
fontSize: 14, color: SojornColors.postContentLight, height: 1.5,
),
textAlign: TextAlign.center,
),
const Spacer(),
Icon(Icons.lock_outline, size: 28, color: AppTheme.navyBlue.withValues(alpha: 0.15)),
const SizedBox(height: 8),
],
),
);
}
}
// Screen 2: Four Ways to Connect
class _FeaturesPage extends StatelessWidget {
const _FeaturesPage();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(28, 36, 28, 8),
child: Column(
children: [
Text('Four Ways to Connect', style: TextStyle(
fontSize: 20, fontWeight: FontWeight.w800, color: AppTheme.navyBlue,
)),
const SizedBox(height: 24),
_FeatureRow(
icon: Icons.article_outlined,
color: const Color(0xFF2196F3),
title: 'Posts',
subtitle: 'Share thoughts with your circle',
),
const SizedBox(height: 14),
_FeatureRow(
icon: Icons.play_circle_outline,
color: const Color(0xFF9C27B0),
title: 'Quips',
subtitle: 'Short videos, your stories',
),
const SizedBox(height: 14),
_FeatureRow(
icon: Icons.forum_outlined,
color: const Color(0xFFFF9800),
title: 'Chains',
subtitle: 'Deep conversations, threaded replies',
),
const SizedBox(height: 14),
_FeatureRow(
icon: Icons.sensors,
color: const Color(0xFF4CAF50),
title: 'Beacons',
subtitle: 'Local alerts and real-time updates',
),
const Spacer(),
],
),
);
}
}
class _FeatureRow extends StatelessWidget {
final IconData icon;
final Color color;
final String title;
final String subtitle;
const _FeatureRow({
required this.icon,
required this.color,
required this.title,
required this.subtitle,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Container(
width: 44, height: 44,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: color, size: 22),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(
fontSize: 15, fontWeight: FontWeight.w700,
)),
const SizedBox(height: 2),
Text(subtitle, style: TextStyle(
fontSize: 12, color: SojornColors.textDisabled,
)),
],
),
),
],
);
}
}
// Screen 3: Build Your Harmony
class _HarmonyPage extends StatelessWidget {
const _HarmonyPage();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(28, 36, 28, 8),
child: Column(
children: [
Container(
width: 72, height: 72,
decoration: BoxDecoration(
color: const Color(0xFF4CAF50).withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(Icons.auto_graph, color: Color(0xFF4CAF50), size: 36),
),
const SizedBox(height: 24),
Text('Build Your Harmony', style: TextStyle(
fontSize: 20, fontWeight: FontWeight.w800, color: AppTheme.navyBlue,
)),
const SizedBox(height: 14),
Text(
'Your Harmony State grows as you contribute positively. Higher harmony means greater reach.',
style: TextStyle(
fontSize: 14, color: SojornColors.postContentLight, height: 1.5,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
// Mini progression chart
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.navyBlue.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.08)),
),
child: Column(
children: [
_HarmonyLevel(label: 'New', range: '0100', multiplier: '1.0x', isActive: true),
const SizedBox(height: 8),
_HarmonyLevel(label: 'Trusted', range: '100500', multiplier: '1.5x', isActive: false),
const SizedBox(height: 8),
_HarmonyLevel(label: 'Pillar', range: '500+', multiplier: '2.0x', isActive: false),
],
),
),
const Spacer(),
],
),
);
}
}
class _HarmonyLevel extends StatelessWidget {
final String label;
final String range;
final String multiplier;
final bool isActive;
const _HarmonyLevel({
required this.label,
required this.range,
required this.multiplier,
required this.isActive,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Container(
width: 10, height: 10,
decoration: BoxDecoration(
color: isActive ? const Color(0xFF4CAF50) : AppTheme.navyBlue.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(label, style: TextStyle(
fontSize: 13, fontWeight: isActive ? FontWeight.w700 : FontWeight.w500,
color: isActive ? AppTheme.navyBlue : SojornColors.textDisabled,
)),
),
Text(range, style: TextStyle(fontSize: 11, color: SojornColors.textDisabled)),
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: isActive
? const Color(0xFF4CAF50).withValues(alpha: 0.1)
: AppTheme.navyBlue.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
),
child: Text(multiplier, style: TextStyle(
fontSize: 11, fontWeight: FontWeight.w700,
color: isActive ? const Color(0xFF4CAF50) : SojornColors.textDisabled,
)),
),
],
);
}
}