diff --git a/app/lib/backend/preferences.dart b/app/lib/backend/preferences.dart index ec54be3124..5dfbb9b7f9 100644 --- a/app/lib/backend/preferences.dart +++ b/app/lib/backend/preferences.dart @@ -187,6 +187,21 @@ class SharedPreferencesUtil { bool get hasViewedWrapped2025 => getBool('hasViewedWrapped2025', defaultValue: false); + // Home onboarding - track completion + bool get hasOpenedBrainMap => getBool('hasOpenedBrainMap', defaultValue: false); + + set hasOpenedBrainMap(bool value) => saveBool('hasOpenedBrainMap', value); + + int get homeOnboardingViewCount => getInt('homeOnboardingViewCount', defaultValue: 0); + + set homeOnboardingViewCount(int value) => saveInt('homeOnboardingViewCount', value); + + void incrementHomeOnboardingViewCount() { + homeOnboardingViewCount = homeOnboardingViewCount + 1; + } + + bool get isHomeOnboardingCompleted => hasOpenedBrainMap || homeOnboardingViewCount >= 10; + set conversationEventsToggled(bool value) => saveBool('conversationEventsToggled', value); bool get conversationEventsToggled => getBool('conversationEventsToggled'); diff --git a/app/lib/pages/action_items/action_items_page.dart b/app/lib/pages/action_items/action_items_page.dart index b055311117..e0a8b55388 100644 --- a/app/lib/pages/action_items/action_items_page.dart +++ b/app/lib/pages/action_items/action_items_page.dart @@ -2,7 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'widgets/action_item_form_sheet.dart'; +import 'widgets/tasks_onboarding_widget.dart'; +import 'package:omi/backend/preferences.dart'; import 'package:omi/backend/schema/schema.dart'; import 'package:omi/providers/action_items_provider.dart'; import 'package:omi/utils/analytics/mixpanel.dart'; @@ -38,16 +40,36 @@ class _ActionItemsPageState extends State with AutomaticKeepAli void initState() { super.initState(); _scrollController.addListener(_onScroll); - WidgetsBinding.instance.addPostFrameCallback((_) { + WidgetsBinding.instance.addPostFrameCallback((_) async { if (!mounted) return; MixpanelManager().actionItemsPageOpened(); final provider = Provider.of(context, listen: false); if (provider.actionItems.isEmpty) { - provider.fetchActionItems(showShimmer: true); + await provider.fetchActionItems(showShimmer: true); + } + + // Create onboarding task if needed + if (!SharedPreferencesUtil().isHomeOnboardingCompleted) { + _createOnboardingTaskIfNeeded(provider); } }); } + void _createOnboardingTaskIfNeeded(ActionItemsProvider provider) { + // Check if "Open your brain map" task already exists + const onboardingTaskDescription = 'Open your brain map'; + final existingTask = provider.actionItems.any( + (item) => item.description.toLowerCase() == onboardingTaskDescription.toLowerCase(), + ); + + if (!existingTask) { + provider.createActionItem( + description: onboardingTaskDescription, + dueAt: null, // No deadline + ); + } + } + @override void dispose() { _scrollController.removeListener(_onScroll); @@ -373,6 +395,10 @@ class _ActionItemsPageState extends State with AutomaticKeepAli ), ), + // Tasks onboarding widget + if (!SharedPreferencesUtil().isHomeOnboardingCompleted) + const SliverToBoxAdapter(child: TasksOnboardingWidget()), + // Bottom padding const SliverPadding(padding: EdgeInsets.only(bottom: 100)), ], diff --git a/app/lib/pages/action_items/widgets/tasks_onboarding_widget.dart b/app/lib/pages/action_items/widgets/tasks_onboarding_widget.dart new file mode 100644 index 0000000000..83268cbd07 --- /dev/null +++ b/app/lib/pages/action_items/widgets/tasks_onboarding_widget.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:omi/backend/preferences.dart'; + +class TasksOnboardingWidget extends StatelessWidget { + const TasksOnboardingWidget({super.key}); + + @override + Widget build(BuildContext context) { + if (SharedPreferencesUtil().isHomeOnboardingCompleted) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Your tasks are created\nautomatically\nfrom conversations and chat", + style: TextStyle( + color: Colors.grey.shade400, + fontSize: 16, + fontWeight: FontWeight.w400, + height: 1.3, + ), + ), + const SizedBox(height: 12), + // Vertical line + Padding( + padding: const EdgeInsets.only(left: 40), + child: Container( + width: 2, + height: 40, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 12), + Text( + "Now let's check your memories", + style: TextStyle( + color: Colors.grey.shade400, + fontSize: 16, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(height: 8), + // Arrow pointing down to memories tab (3rd icon, right of center) + Padding( + padding: const EdgeInsets.only(left: 220), + child: CustomPaint( + size: const Size(40, 70), + painter: _ArrowDownPainter(), + ), + ), + ], + ), + ); + } +} + +// Arrow pointing straight down +class _ArrowDownPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = Colors.grey.shade600 + ..strokeWidth = 2 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round; + + // Curved line going down + final path = Path(); + path.moveTo(size.width * 0.5, 0); + path.quadraticBezierTo( + size.width * 0.6, + size.height * 0.5, + size.width * 0.5, + size.height * 0.85, + ); + + canvas.drawPath(path, paint); + + // Arrow head pointing down + final arrowPath = Path(); + arrowPath.moveTo(size.width * 0.5 - 6, size.height * 0.72); + arrowPath.lineTo(size.width * 0.5, size.height * 0.85); + arrowPath.lineTo(size.width * 0.5 + 6, size.height * 0.72); + + canvas.drawPath(arrowPath, paint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/app/lib/pages/conversations/conversations_page.dart b/app/lib/pages/conversations/conversations_page.dart index 62fe4a769f..1e73601836 100644 --- a/app/lib/pages/conversations/conversations_page.dart +++ b/app/lib/pages/conversations/conversations_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:omi/backend/preferences.dart'; import 'package:omi/backend/schema/conversation.dart'; import 'package:omi/pages/capture/widgets/widgets.dart'; import 'package:omi/pages/conversations/widgets/processing_capture.dart'; @@ -22,6 +23,7 @@ import 'package:visibility_detector/visibility_detector.dart'; import 'widgets/empty_conversations.dart'; import 'widgets/conversations_group_widget.dart'; import 'widgets/goal_tracker_widget.dart'; +import 'widgets/home_onboarding_widget.dart'; class ConversationsPage extends StatefulWidget { const ConversationsPage({super.key}); @@ -63,6 +65,11 @@ class _ConversationsPageState extends State with AutomaticKee if (mounted && conversationProvider.conversations.isNotEmpty) { await _appReviewService.showReviewPromptIfNeeded(context, isProcessingFirstConversation: true); } + + // Increment onboarding view count if not completed + if (!SharedPreferencesUtil().isHomeOnboardingCompleted) { + SharedPreferencesUtil().incrementHomeOnboardingViewCount(); + } }); } @@ -229,7 +236,8 @@ class _ConversationsPageState extends State with AutomaticKee const DailySummariesList() else if (convoProvider.groupedConversations.isEmpty && !convoProvider.isLoadingConversations && - !convoProvider.isFetchingConversations) + !convoProvider.isFetchingConversations && + SharedPreferencesUtil().isHomeOnboardingCompleted) SliverToBoxAdapter( child: Center( child: Padding( @@ -290,6 +298,9 @@ class _ConversationsPageState extends State with AutomaticKee }, ), ), + // Home onboarding widget - shown AFTER conversations + if (!SharedPreferencesUtil().isHomeOnboardingCompleted) + const SliverToBoxAdapter(child: HomeOnboardingWidget()), SliverToBoxAdapter( child: SizedBox(height: convoProvider.isSelectionModeActive ? 160 : 100), ), diff --git a/app/lib/pages/conversations/widgets/home_onboarding_widget.dart b/app/lib/pages/conversations/widgets/home_onboarding_widget.dart new file mode 100644 index 0000000000..402590e777 --- /dev/null +++ b/app/lib/pages/conversations/widgets/home_onboarding_widget.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:omi/backend/preferences.dart'; + +class HomeOnboardingWidget extends StatelessWidget { + const HomeOnboardingWidget({super.key}); + + @override + Widget build(BuildContext context) { + if (SharedPreferencesUtil().isHomeOnboardingCompleted) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Here you'll see your\nconversations", + style: TextStyle( + color: Colors.grey.shade400, + fontSize: 18, + fontWeight: FontWeight.w400, + height: 1.3, + ), + ), + const SizedBox(height: 12), + // Vertical line + Padding( + padding: const EdgeInsets.only(left: 40), + child: Container( + width: 2, + height: 40, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 12), + Text( + "Let's check your tasks", + style: TextStyle( + color: Colors.grey.shade400, + fontSize: 18, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(height: 8), + // Arrow pointing down to tasks tab (2nd icon) + Padding( + padding: const EdgeInsets.only(left: 85), + child: CustomPaint( + size: const Size(40, 70), + painter: _ArrowDownPainter(), + ), + ), + ], + ), + ); + } +} + +// Arrow pointing straight down +class _ArrowDownPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = Colors.grey.shade600 + ..strokeWidth = 2 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round; + + // Curved line going down + final path = Path(); + path.moveTo(size.width * 0.5, 0); + path.quadraticBezierTo( + size.width * 0.6, + size.height * 0.5, + size.width * 0.5, + size.height * 0.85, + ); + + canvas.drawPath(path, paint); + + // Arrow head pointing down + final arrowPath = Path(); + arrowPath.moveTo(size.width * 0.5 - 6, size.height * 0.72); + arrowPath.lineTo(size.width * 0.5, size.height * 0.85); + arrowPath.lineTo(size.width * 0.5 + 6, size.height * 0.72); + + canvas.drawPath(arrowPath, paint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/app/lib/pages/conversations/widgets/wrapped_banner.dart b/app/lib/pages/conversations/widgets/wrapped_banner.dart index 37f5c3a8b6..b5c5e82780 100644 --- a/app/lib/pages/conversations/widgets/wrapped_banner.dart +++ b/app/lib/pages/conversations/widgets/wrapped_banner.dart @@ -32,6 +32,9 @@ class _WrappedBannerState extends State with SingleTickerProvider @override Widget build(BuildContext context) { + // Hide wrapped banner for now + return const SizedBox.shrink(); + // Don't show banner if user has already viewed their wrapped if (SharedPreferencesUtil().hasViewedWrapped2025) { return const SizedBox.shrink(); diff --git a/app/lib/pages/memories/page.dart b/app/lib/pages/memories/page.dart index 36ddeca0d4..01e1b19daf 100644 --- a/app/lib/pages/memories/page.dart +++ b/app/lib/pages/memories/page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:omi/backend/preferences.dart'; import 'package:omi/backend/schema/memory.dart'; import 'package:omi/providers/home_provider.dart'; import 'package:omi/providers/memories_provider.dart'; @@ -13,6 +14,7 @@ import 'package:shimmer/shimmer.dart'; import 'widgets/memory_edit_sheet.dart'; import 'widgets/memory_item.dart'; import 'widgets/memory_dialog.dart'; +import 'widgets/memories_onboarding_widget.dart'; import 'package:omi/utils/l10n_extensions.dart'; @@ -311,6 +313,10 @@ class MemoriesPageState extends State with AutomaticKeepAliveClien height: 44, child: ElevatedButton( onPressed: () { + // Mark onboarding as completed when brain map is opened + if (!SharedPreferencesUtil().hasOpenedBrainMap) { + SharedPreferencesUtil().hasOpenedBrainMap = true; + } Navigator.of(context).push( MaterialPageRoute( builder: (context) => const MemoryGraphPage(), @@ -351,6 +357,9 @@ class MemoriesPageState extends State with AutomaticKeepAliveClien ), ), ), + // Onboarding text widget + if (!SharedPreferencesUtil().isHomeOnboardingCompleted) + const SliverToBoxAdapter(child: MemoriesOnboardingWidget()), if (provider.filteredMemories.isEmpty) SliverFillRemaining( child: Center( diff --git a/app/lib/pages/memories/widgets/memories_onboarding_widget.dart b/app/lib/pages/memories/widgets/memories_onboarding_widget.dart new file mode 100644 index 0000000000..447ddb2397 --- /dev/null +++ b/app/lib/pages/memories/widgets/memories_onboarding_widget.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:omi/backend/preferences.dart'; + +class MemoriesOnboardingWidget extends StatelessWidget { + const MemoriesOnboardingWidget({super.key}); + + @override + Widget build(BuildContext context) { + if (SharedPreferencesUtil().isHomeOnboardingCompleted) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Arrow pointing up to brain icon - positioned to align with brain button + Align( + alignment: Alignment.centerRight, + child: Padding( + // Position arrow under the brain button (first icon after search) + padding: const EdgeInsets.only(right: 44), + child: const MemoriesOnboardingArrow(), + ), + ), + const SizedBox(height: 16), + Text( + "Let's see your brain!", + style: TextStyle( + color: Colors.grey.shade400, + fontSize: 18, + fontWeight: FontWeight.w400, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} + +// Arrow that points UP to the brain icon from below +class MemoriesOnboardingArrow extends StatelessWidget { + const MemoriesOnboardingArrow({super.key}); + + @override + Widget build(BuildContext context) { + if (SharedPreferencesUtil().isHomeOnboardingCompleted) { + return const SizedBox.shrink(); + } + + // Arrow pointing up from below the brain icon + return SizedBox( + width: 44, + height: 45, + child: Center( + child: CustomPaint( + size: const Size(20, 40), + painter: _ArrowUpPainter(), + ), + ), + ); + } +} + +// Arrow pointing up +class _ArrowUpPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = Colors.grey.shade500 + ..strokeWidth = 2 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round; + + // Vertical line going up + final path = Path(); + path.moveTo(size.width * 0.5, size.height); + path.lineTo(size.width * 0.5, size.height * 0.2); + + canvas.drawPath(path, paint); + + // Arrow head pointing up + final arrowPath = Path(); + arrowPath.moveTo(size.width * 0.5 - 5, size.height * 0.35); + arrowPath.lineTo(size.width * 0.5, size.height * 0.2); + arrowPath.lineTo(size.width * 0.5 + 5, size.height * 0.35); + + canvas.drawPath(arrowPath, paint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/app/lib/pages/onboarding/speech_profile_widget.dart b/app/lib/pages/onboarding/speech_profile_widget.dart index f06cc119f1..eb66254b2b 100644 --- a/app/lib/pages/onboarding/speech_profile_widget.dart +++ b/app/lib/pages/onboarding/speech_profile_widget.dart @@ -1,7 +1,9 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_provider_utilities/flutter_provider_utilities.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:omi/backend/preferences.dart'; import 'package:omi/pages/settings/language_selection_dialog.dart'; import 'package:omi/pages/speech_profile/percentage_bar_progress.dart'; @@ -290,55 +292,70 @@ class _SpeechProfileWidgetState extends State with TickerPr mainAxisAlignment: MainAxisAlignment.end, children: [ const SizedBox(height: 20), - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 0), - decoration: BoxDecoration( - border: const GradientBoxBorder( - gradient: LinearGradient(colors: [ - Color.fromARGB(127, 208, 208, 208), - Color.fromARGB(127, 188, 99, 121), - Color.fromARGB(127, 86, 101, 182), - Color.fromARGB(127, 126, 190, 236) - ]), - width: 2, + Material( + elevation: 4, + color: Colors.transparent, + shape: const CircleBorder(), + child: Container( + height: 56, + width: 56, + decoration: BoxDecoration( + color: const Color(0xFF6B46C1), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + spreadRadius: 1, + blurRadius: 5, + offset: const Offset(0, 2), + ), + ], ), - borderRadius: BorderRadius.circular(12), - ), - child: TextButton( - onPressed: () async { - // Check if user has set primary language, if not, show dialog - if (!context.read().hasSetPrimaryLanguage) { - await LanguageSelectionDialog.show(context); - } + child: Material( + color: Colors.transparent, + shape: const CircleBorder(), + child: InkWell( + borderRadius: BorderRadius.circular(28), + onTap: () async { + HapticFeedback.mediumImpact(); + // Check if user has set primary language, if not, show dialog + if (!context.read().hasSetPrimaryLanguage) { + await LanguageSelectionDialog.show(context); + } - await stopAllRecording(); + await stopAllRecording(); - // Initialize speech profile with phone mic as input source - // Don't pass restartDeviceRecording - we don't want to restart device recording - bool success = await provider.initialise( - usePhoneMic: true, - processConversationCallback: () { - Provider.of(context, listen: false) - .forceProcessingCurrentConversation(); - }, - ); + // Initialize speech profile with phone mic as input source + // Don't pass restartDeviceRecording - we don't want to restart device recording + bool success = await provider.initialise( + usePhoneMic: true, + processConversationCallback: () { + Provider.of(context, listen: false) + .forceProcessingCurrentConversation(); + }, + ); - if (!success) { - // Initialization failed, error dialog will be shown - return; - } + if (!success) { + // Initialization failed, error dialog will be shown + return; + } - provider.forceCompletionTimer = - Timer(Duration(seconds: provider.maxDuration), () async { - provider.finalize(); - }); + provider.forceCompletionTimer = + Timer(Duration(seconds: provider.maxDuration), () async { + provider.finalize(); + }); - // Start question animation - _questionAnimationController.forward(); - }, - child: Text( - context.l10n.getStarted, - style: const TextStyle(color: Colors.white, fontSize: 16), + // Start question animation + _questionAnimationController.forward(); + }, + child: Center( + child: FaIcon( + FontAwesomeIcons.microphone, + color: Colors.white, + size: 22, + ), + ), + ), ), ), ), diff --git a/app/lib/pages/speech_profile/page.dart b/app/lib/pages/speech_profile/page.dart index 1fb7fe5f6a..914c587745 100644 --- a/app/lib/pages/speech_profile/page.dart +++ b/app/lib/pages/speech_profile/page.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_provider_utilities/flutter_provider_utilities.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:omi/backend/preferences.dart'; import 'package:omi/backend/schema/bt_device/bt_device.dart'; import 'package:omi/pages/home/page.dart'; @@ -363,8 +364,8 @@ class _SpeechProfilePageState extends State with TickerProvid ? const CircularProgressIndicator( color: Colors.white, ) - : MaterialButton( - onPressed: () async { + : GestureDetector( + onTap: () async { // Check if user has set primary language, if not, show dialog if (!context.read().hasSetPrimaryLanguage) { await LanguageSelectionDialog.show(context); @@ -410,15 +411,51 @@ class _SpeechProfilePageState extends State with TickerProvid provider.updateStartedRecording(true); _questionAnimationController.forward(); }, - color: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), - child: Text( - SharedPreferencesUtil().hasSpeakerProfile ? 'Do it again' : 'Get Started', - style: const TextStyle(color: Colors.black), + child: Material( + elevation: 4, + color: Colors.transparent, + shape: const CircleBorder(), + child: Container( + height: 56, + width: 56, + decoration: BoxDecoration( + color: const Color(0xFF6B46C1), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + spreadRadius: 1, + blurRadius: 5, + offset: const Offset(0, 2), + ), + ], + ), + child: Center( + child: FaIcon( + FontAwesomeIcons.microphone, + color: Colors.white, + size: 22, + ), + ), + ), ), ), - const SizedBox(height: 24), + const SizedBox(height: 16), + if (widget.onbording) + TextButton( + onPressed: () { + routeToPage(context, const HomePageWrapper(), replace: true); + }, + child: const Text( + 'Skip for now', + style: TextStyle( + color: Colors.white, + decoration: TextDecoration.underline, + decorationColor: Colors.white, + ), + ), + ), + if (!widget.onbording) const SizedBox(height: 24), SharedPreferencesUtil().hasSpeakerProfile ? TextButton( onPressed: () {