From 2c6ced5c149669b3e9bbfb734088f494dbe9e5db Mon Sep 17 00:00:00 2001 From: ytc1012 <18001193130@163.com> Date: Mon, 24 Nov 2025 10:33:09 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=95=E5=AF=BC=E3=80=81=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E6=A0=8F=E8=AE=A1=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 4 +- lib/main.dart | 37 +++- lib/screens/focus_screen.dart | 81 ++++++++- lib/screens/onboarding_screen.dart | 243 +++++++++++++++++++++++++ lib/screens/settings_screen.dart | 54 ++++++ lib/services/notification_service.dart | 82 +++++++++ test/widget_test.dart | 23 +-- 7 files changed, 496 insertions(+), 28 deletions(-) create mode 100644 lib/screens/onboarding_screen.dart diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 46036d7..824631f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -28,7 +28,9 @@ "Bash(gem install:*)", "Bash(export PATH=\"$PATH:$HOME/sdk/flutter/bin\")", "Bash(flutter doctor:*)", - "Bash(flutter analyze:*)" + "Bash(flutter analyze:*)", + "Bash(start \"\" \"f:\\cursor-auto\\focusBuddy\\onboarding-preview.html\")", + "Bash(flutter emulators:*)" ], "deny": [], "ask": [] diff --git a/lib/main.dart b/lib/main.dart index 8043329..7404b51 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ import 'services/storage_service.dart'; import 'services/encouragement_service.dart'; import 'services/notification_service.dart'; import 'screens/home_screen.dart'; +import 'screens/onboarding_screen.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -23,7 +24,7 @@ void main() async { runApp(MyApp(encouragementService: encouragementService)); } -class MyApp extends StatelessWidget { +class MyApp extends StatefulWidget { final EncouragementService encouragementService; const MyApp({ @@ -31,13 +32,45 @@ class MyApp extends StatelessWidget { required this.encouragementService, }); + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + bool _hasCompletedOnboarding = false; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _checkOnboardingStatus(); + } + + Future _checkOnboardingStatus() async { + final completed = await OnboardingScreen.hasCompletedOnboarding(); + setState(() { + _hasCompletedOnboarding = completed; + _isLoading = false; + }); + } + @override Widget build(BuildContext context) { return MaterialApp( title: 'FocusBuddy', debugShowCheckedModeBanner: false, theme: AppTheme.lightTheme, - home: HomeScreen(encouragementService: encouragementService), + home: _isLoading + ? const Scaffold( + body: Center( + child: CircularProgressIndicator(), + ), + ) + : _hasCompletedOnboarding + ? HomeScreen(encouragementService: widget.encouragementService) + : OnboardingScreen( + encouragementService: widget.encouragementService, + ), ); } } diff --git a/lib/screens/focus_screen.dart b/lib/screens/focus_screen.dart index 8a721d5..9072235 100644 --- a/lib/screens/focus_screen.dart +++ b/lib/screens/focus_screen.dart @@ -24,21 +24,66 @@ class FocusScreen extends StatefulWidget { State createState() => _FocusScreenState(); } -class _FocusScreenState extends State { +class _FocusScreenState extends State with WidgetsBindingObserver { late Timer _timer; late int _remainingSeconds; late DateTime _startTime; final List _distractions = []; bool _isPaused = false; + bool _isInBackground = false; + final NotificationService _notificationService = NotificationService(); @override void initState() { super.initState(); _remainingSeconds = widget.durationMinutes * 60; _startTime = DateTime.now(); + WidgetsBinding.instance.addObserver(this); _startTimer(); } + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _timer.cancel(); + // Cancel ongoing notification when leaving the screen + _notificationService.cancelOngoingFocusNotification(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + + switch (state) { + case AppLifecycleState.paused: + case AppLifecycleState.inactive: + case AppLifecycleState.detached: + // App went to background + _isInBackground = true; + if (!_isPaused && _remainingSeconds > 0) { + _showBackgroundNotification(); + } + break; + case AppLifecycleState.resumed: + // App came back to foreground + _isInBackground = false; + _notificationService.cancelOngoingFocusNotification(); + break; + case AppLifecycleState.hidden: + break; + } + } + + void _showBackgroundNotification() { + final minutes = _remainingSeconds ~/ 60; + final seconds = _remainingSeconds % 60; + _notificationService.showOngoingFocusNotification( + remainingMinutes: minutes, + remainingSeconds: seconds, + ); + } + void _startTimer() { _timer = Timer.periodic(const Duration(seconds: 1), (timer) { if (!_isPaused && _remainingSeconds > 0) { @@ -46,6 +91,18 @@ class _FocusScreenState extends State { _remainingSeconds--; }); + // Update background notification every 30 seconds when in background + if (_isInBackground && _remainingSeconds > 0) { + if (_remainingSeconds % 30 == 0) { + final minutes = _remainingSeconds ~/ 60; + final seconds = _remainingSeconds % 60; + _notificationService.updateOngoingFocusNotification( + remainingMinutes: minutes, + remainingSeconds: seconds, + ); + } + } + if (_remainingSeconds == 0) { _onTimerComplete(); } @@ -55,11 +112,14 @@ class _FocusScreenState extends State { void _onTimerComplete() async { _timer.cancel(); + + // Cancel ongoing notification and show completion notification + await _notificationService.cancelOngoingFocusNotification(); + _saveFocusSession(completed: true); - // Send notification - final notificationService = NotificationService(); - await notificationService.showFocusCompletedNotification( + // Send completion notification + await _notificationService.showFocusCompletedNotification( minutes: widget.durationMinutes, distractionCount: _distractions.length, ); @@ -82,6 +142,13 @@ class _FocusScreenState extends State { setState(() { _isPaused = !_isPaused; }); + + // Update notification when paused + if (_isPaused && _isInBackground) { + _notificationService.cancelOngoingFocusNotification(); + } else if (!_isPaused && _isInBackground) { + _showBackgroundNotification(); + } } void _stopEarly() { @@ -257,12 +324,6 @@ class _FocusScreenState extends State { return '${minutes.toString().padLeft(2, '0')}:${secs.toString().padLeft(2, '0')}'; } - @override - void dispose() { - _timer.cancel(); - super.dispose(); - } - @override Widget build(BuildContext context) { return Scaffold( diff --git a/lib/screens/onboarding_screen.dart b/lib/screens/onboarding_screen.dart new file mode 100644 index 0000000..bedd20e --- /dev/null +++ b/lib/screens/onboarding_screen.dart @@ -0,0 +1,243 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../theme/app_colors.dart'; +import '../theme/app_text_styles.dart'; +import 'home_screen.dart'; +import '../services/encouragement_service.dart'; + +/// Onboarding Screen - Shows on first launch +class OnboardingScreen extends StatefulWidget { + final EncouragementService encouragementService; + + const OnboardingScreen({ + super.key, + required this.encouragementService, + }); + + /// Check if user has completed onboarding + static Future hasCompletedOnboarding() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_onboardingKey) ?? false; + } + + /// Mark onboarding as completed + static Future setOnboardingCompleted() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_onboardingKey, true); + } + + static const String _onboardingKey = 'onboarding_completed'; + + @override + State createState() => _OnboardingScreenState(); +} + +class _OnboardingScreenState extends State { + final PageController _pageController = PageController(); + int _currentPage = 0; + + final List _pages = [ + OnboardingPage( + emoji: '💚', + title: 'Focus without guilt', + description: + "This app is different — it won't punish you for losing focus.\n\nPerfect for ADHD, anxiety, or anyone who finds traditional timers too harsh.", + ), + OnboardingPage( + emoji: '🤚', + title: 'Tap when you get distracted', + description: + "We'll gently remind you to come back.\n\nNo shame. No stress. Just a friendly nudge.", + ), + OnboardingPage( + emoji: '📊', + title: 'Track your progress', + description: + "See how you're improving, one session at a time.\n\nEvery distraction is just data — not failure.", + ), + ]; + + void _onPageChanged(int page) { + setState(() { + _currentPage = page; + }); + } + + void _nextPage() { + if (_currentPage < _pages.length - 1) { + _pageController.animateToPage( + _currentPage + 1, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } else { + _completeOnboarding(); + } + } + + void _skipOnboarding() { + _completeOnboarding(); + } + + Future _completeOnboarding() async { + await OnboardingScreen.setOnboardingCompleted(); + + if (!mounted) return; + + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => HomeScreen( + encouragementService: widget.encouragementService, + ), + ), + ); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + child: Column( + children: [ + // Skip button + Align( + alignment: Alignment.topRight, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: TextButton( + onPressed: _skipOnboarding, + child: const Text( + 'Skip', + style: TextStyle( + fontFamily: 'Nunito', + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ), + ), + ), + + // PageView + Expanded( + child: PageView.builder( + controller: _pageController, + onPageChanged: _onPageChanged, + itemCount: _pages.length, + itemBuilder: (context, index) { + return _buildPage(_pages[index]); + }, + ), + ), + + // Page indicators + Padding( + padding: const EdgeInsets.symmetric(vertical: 24.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + _pages.length, + (index) => _buildIndicator(index == _currentPage), + ), + ), + ), + + // Next/Get Started button + Padding( + padding: const EdgeInsets.all(24.0), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _nextPage, + child: Text( + _currentPage == _pages.length - 1 + ? 'Get Started' + : 'Next', + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildPage(OnboardingPage page) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Emoji + Text( + page.emoji, + style: const TextStyle(fontSize: 80), + ), + + const SizedBox(height: 48), + + // Title + Text( + page.title, + style: const TextStyle( + fontFamily: 'Nunito', + fontSize: 32, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + textAlign: TextAlign.center, + ), + + const SizedBox(height: 24), + + // Description + Text( + page.description, + style: AppTextStyles.bodyText.copyWith( + fontSize: 18, + height: 1.6, + ), + textAlign: TextAlign.center, + ), + + const SizedBox(height: 40), + ], + ), + ); + } + + Widget _buildIndicator(bool isActive) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 4), + width: isActive ? 24 : 8, + height: 8, + decoration: BoxDecoration( + color: isActive ? AppColors.primary : AppColors.divider, + borderRadius: BorderRadius.circular(4), + ), + ); + } +} + +/// Data class for onboarding page +class OnboardingPage { + final String emoji; + final String title; + final String description; + + OnboardingPage({ + required this.emoji, + required this.title, + required this.description, + }); +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index f03fc90..9d83c9c 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -110,6 +110,24 @@ class _SettingsScreenState extends State { _showAboutDialog(); }, ), + const Divider(color: AppColors.divider), + ListTile( + contentPadding: EdgeInsets.zero, + title: Text( + 'Reset Onboarding', + style: AppTextStyles.bodyText.copyWith( + color: AppColors.textSecondary, + ), + ), + trailing: const Icon( + Icons.refresh, + size: 16, + color: AppColors.textSecondary, + ), + onTap: () { + _resetOnboarding(); + }, + ), ], ), @@ -318,4 +336,40 @@ class _SettingsScreenState extends State { ), ); } + + void _resetOnboarding() async { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Reset Onboarding?'), + content: Text( + 'This will show the onboarding screens again when you restart the app.', + style: AppTextStyles.bodyText, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('onboarding_completed'); + + if (!context.mounted) return; + + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Onboarding reset. Restart the app to see it again.'), + duration: Duration(seconds: 3), + ), + ); + }, + child: const Text('Reset'), + ), + ], + ), + ); + } } diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 05cfa9f..caa355a 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -189,6 +189,88 @@ class NotificationService { } } + /// Cancel a specific notification by ID + Future cancel(int id) async { + if (kIsWeb || !_initialized) return; + + try { + await _notifications.cancel(id); + } catch (e) { + if (kDebugMode) { + print('Failed to cancel notification $id: $e'); + } + } + } + + /// Show ongoing focus session notification (for background) + /// This notification stays visible while the timer is running + Future showOngoingFocusNotification({ + required int remainingMinutes, + required int remainingSeconds, + }) async { + if (kIsWeb || !_initialized) return; + + try { + // Format time display + final timeStr = '${remainingMinutes.toString().padLeft(2, '0')}:${(remainingSeconds % 60).toString().padLeft(2, '0')}'; + + const androidDetails = AndroidNotificationDetails( + 'focus_timer', + 'Focus Timer', + channelDescription: 'Shows ongoing focus session timer', + importance: Importance.low, + priority: Priority.low, + ongoing: true, // Makes notification persistent + autoCancel: false, + showWhen: false, + enableVibration: false, + playSound: false, + // Show in status bar + showProgress: false, + ); + + const iosDetails = DarwinNotificationDetails( + presentAlert: true, + presentBadge: false, + presentSound: false, + ); + + const notificationDetails = NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ); + + await _notifications.show( + 2, // Use ID 2 for ongoing notifications + '⏱️ Focus session in progress', + '$timeStr remaining', + notificationDetails, + payload: 'focus_ongoing', + ); + } catch (e) { + if (kDebugMode) { + print('Failed to show ongoing notification: $e'); + } + } + } + + /// Update ongoing notification with new time + Future updateOngoingFocusNotification({ + required int remainingMinutes, + required int remainingSeconds, + }) async { + // On Android, showing the same notification ID updates it + await showOngoingFocusNotification( + remainingMinutes: remainingMinutes, + remainingSeconds: remainingSeconds, + ); + } + + /// Cancel ongoing focus notification + Future cancelOngoingFocusNotification() async { + await cancel(2); // Cancel notification with ID 2 + } + /// Check if notifications are supported on this platform bool get isSupported => !kIsWeb; } diff --git a/test/widget_test.dart b/test/widget_test.dart index 8f575dd..a9139b4 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -5,26 +5,19 @@ // gestures. You can also use WidgetTester to find child widgets in the widget // tree, read text, and verify that the values of widget properties are correct. -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; - +import 'package:focus_buddy/services/encouragement_service.dart'; import 'package:focus_buddy/main.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { + testWidgets('App loads successfully', (WidgetTester tester) async { + // Create a mock encouragement service + final encouragementService = EncouragementService(); + // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); + await tester.pumpWidget(MyApp(encouragementService: encouragementService)); - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + // Verify that the app loads + expect(find.byType(MyApp), findsOneWidget); }); }