feat: Phase 1.2 - 3-screen onboarding modal (Welcome, Features, Harmony)
This commit is contained in:
parent
c255386db5
commit
0c183c3491
|
|
@ -14,6 +14,7 @@ import '../beacon/beacon_screen.dart';
|
|||
import '../quips/create/quip_creation_flow.dart';
|
||||
import '../secure_chat/secure_chat_full_screen.dart';
|
||||
import '../../widgets/radial_menu_overlay.dart';
|
||||
import '../../widgets/onboarding_modal.dart';
|
||||
import '../../providers/quip_upload_provider.dart';
|
||||
import '../../providers/notification_provider.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
|
@ -40,6 +41,9 @@ class _HomeShellState extends ConsumerState<HomeShell> with WidgetsBindingObserv
|
|||
WidgetsBinding.instance.addObserver(this);
|
||||
_chatService.startBackgroundSync();
|
||||
_initNotificationListener();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) OnboardingModal.showIfNeeded(context);
|
||||
});
|
||||
}
|
||||
|
||||
void _initNotificationListener() {
|
||||
|
|
|
|||
397
sojorn_app/lib/widgets/onboarding_modal.dart
Normal file
397
sojorn_app/lib/widgets/onboarding_modal.dart
Normal 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: '0–100', multiplier: '1.0x', isActive: true),
|
||||
const SizedBox(height: 8),
|
||||
_HarmonyLevel(label: 'Trusted', range: '100–500', 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,
|
||||
)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue