引导、通知栏计时

This commit is contained in:
ytc1012
2025-11-24 10:33:09 +08:00
parent 149d1ed6cd
commit 2c6ced5c14
7 changed files with 496 additions and 28 deletions

View File

@@ -28,7 +28,9 @@
"Bash(gem install:*)", "Bash(gem install:*)",
"Bash(export PATH=\"$PATH:$HOME/sdk/flutter/bin\")", "Bash(export PATH=\"$PATH:$HOME/sdk/flutter/bin\")",
"Bash(flutter doctor:*)", "Bash(flutter doctor:*)",
"Bash(flutter analyze:*)" "Bash(flutter analyze:*)",
"Bash(start \"\" \"f:\\cursor-auto\\focusBuddy\\onboarding-preview.html\")",
"Bash(flutter emulators:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

View File

@@ -4,6 +4,7 @@ import 'services/storage_service.dart';
import 'services/encouragement_service.dart'; import 'services/encouragement_service.dart';
import 'services/notification_service.dart'; import 'services/notification_service.dart';
import 'screens/home_screen.dart'; import 'screens/home_screen.dart';
import 'screens/onboarding_screen.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -23,7 +24,7 @@ void main() async {
runApp(MyApp(encouragementService: encouragementService)); runApp(MyApp(encouragementService: encouragementService));
} }
class MyApp extends StatelessWidget { class MyApp extends StatefulWidget {
final EncouragementService encouragementService; final EncouragementService encouragementService;
const MyApp({ const MyApp({
@@ -31,13 +32,45 @@ class MyApp extends StatelessWidget {
required this.encouragementService, required this.encouragementService,
}); });
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
bool _hasCompletedOnboarding = false;
bool _isLoading = true;
@override
void initState() {
super.initState();
_checkOnboardingStatus();
}
Future<void> _checkOnboardingStatus() async {
final completed = await OnboardingScreen.hasCompletedOnboarding();
setState(() {
_hasCompletedOnboarding = completed;
_isLoading = false;
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
title: 'FocusBuddy', title: 'FocusBuddy',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme, theme: AppTheme.lightTheme,
home: HomeScreen(encouragementService: encouragementService), home: _isLoading
? const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
)
: _hasCompletedOnboarding
? HomeScreen(encouragementService: widget.encouragementService)
: OnboardingScreen(
encouragementService: widget.encouragementService,
),
); );
} }
} }

View File

@@ -24,21 +24,66 @@ class FocusScreen extends StatefulWidget {
State<FocusScreen> createState() => _FocusScreenState(); State<FocusScreen> createState() => _FocusScreenState();
} }
class _FocusScreenState extends State<FocusScreen> { class _FocusScreenState extends State<FocusScreen> with WidgetsBindingObserver {
late Timer _timer; late Timer _timer;
late int _remainingSeconds; late int _remainingSeconds;
late DateTime _startTime; late DateTime _startTime;
final List<String> _distractions = []; final List<String> _distractions = [];
bool _isPaused = false; bool _isPaused = false;
bool _isInBackground = false;
final NotificationService _notificationService = NotificationService();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_remainingSeconds = widget.durationMinutes * 60; _remainingSeconds = widget.durationMinutes * 60;
_startTime = DateTime.now(); _startTime = DateTime.now();
WidgetsBinding.instance.addObserver(this);
_startTimer(); _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() { void _startTimer() {
_timer = Timer.periodic(const Duration(seconds: 1), (timer) { _timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!_isPaused && _remainingSeconds > 0) { if (!_isPaused && _remainingSeconds > 0) {
@@ -46,6 +91,18 @@ class _FocusScreenState extends State<FocusScreen> {
_remainingSeconds--; _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) { if (_remainingSeconds == 0) {
_onTimerComplete(); _onTimerComplete();
} }
@@ -55,11 +112,14 @@ class _FocusScreenState extends State<FocusScreen> {
void _onTimerComplete() async { void _onTimerComplete() async {
_timer.cancel(); _timer.cancel();
// Cancel ongoing notification and show completion notification
await _notificationService.cancelOngoingFocusNotification();
_saveFocusSession(completed: true); _saveFocusSession(completed: true);
// Send notification // Send completion notification
final notificationService = NotificationService(); await _notificationService.showFocusCompletedNotification(
await notificationService.showFocusCompletedNotification(
minutes: widget.durationMinutes, minutes: widget.durationMinutes,
distractionCount: _distractions.length, distractionCount: _distractions.length,
); );
@@ -82,6 +142,13 @@ class _FocusScreenState extends State<FocusScreen> {
setState(() { setState(() {
_isPaused = !_isPaused; _isPaused = !_isPaused;
}); });
// Update notification when paused
if (_isPaused && _isInBackground) {
_notificationService.cancelOngoingFocusNotification();
} else if (!_isPaused && _isInBackground) {
_showBackgroundNotification();
}
} }
void _stopEarly() { void _stopEarly() {
@@ -257,12 +324,6 @@ class _FocusScreenState extends State<FocusScreen> {
return '${minutes.toString().padLeft(2, '0')}:${secs.toString().padLeft(2, '0')}'; return '${minutes.toString().padLeft(2, '0')}:${secs.toString().padLeft(2, '0')}';
} }
@override
void dispose() {
_timer.cancel();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(

View File

@@ -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<bool> hasCompletedOnboarding() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_onboardingKey) ?? false;
}
/// Mark onboarding as completed
static Future<void> setOnboardingCompleted() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_onboardingKey, true);
}
static const String _onboardingKey = 'onboarding_completed';
@override
State<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends State<OnboardingScreen> {
final PageController _pageController = PageController();
int _currentPage = 0;
final List<OnboardingPage> _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<void> _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,
});
}

View File

@@ -110,6 +110,24 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showAboutDialog(); _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<SettingsScreen> {
), ),
); );
} }
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'),
),
],
),
);
}
} }

View File

@@ -189,6 +189,88 @@ class NotificationService {
} }
} }
/// Cancel a specific notification by ID
Future<void> 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<void> 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<void> 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<void> cancelOngoingFocusNotification() async {
await cancel(2); // Cancel notification with ID 2
}
/// Check if notifications are supported on this platform /// Check if notifications are supported on this platform
bool get isSupported => !kIsWeb; bool get isSupported => !kIsWeb;
} }

View File

@@ -5,26 +5,19 @@
// gestures. You can also use WidgetTester to find child widgets in the widget // 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. // 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:flutter_test/flutter_test.dart';
import 'package:focus_buddy/services/encouragement_service.dart';
import 'package:focus_buddy/main.dart'; import 'package:focus_buddy/main.dart';
void main() { 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. // 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. // Verify that the app loads
expect(find.text('0'), findsOneWidget); expect(find.byType(MyApp), 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);
}); });
} }