多语言支持

This commit is contained in:
ytc1012
2025-11-24 11:25:33 +08:00
parent 2c6ced5c14
commit 4444c401b9
14 changed files with 672 additions and 167 deletions

3
l10n.yaml Normal file
View File

@@ -0,0 +1,3 @@
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart

219
lib/l10n/app_en.arb Normal file
View File

@@ -0,0 +1,219 @@
{
"@@locale": "en",
"appTitle": "FocusBuddy",
"@appTitle": {
"description": "The application title"
},
"startFocusing": "Start Focusing",
"@startFocusing": {
"description": "Button text to start a focus session"
},
"minutes": "{count, plural, =1{minute} other{minutes}}",
"@minutes": {
"description": "Minutes plural form",
"placeholders": {
"count": {
"type": "int"
}
}
},
"minutesValue": "{count} {minutes}",
"@minutesValue": {
"description": "Minutes with value",
"placeholders": {
"count": {
"type": "int"
},
"minutes": {}
}
},
"tapDistractionAnytime": "Tap 'I got distracted'\nanytime — no guilt.",
"@tapDistractionAnytime": {
"description": "Helper text on home screen"
},
"history": "History",
"@history": {
"description": "History navigation button"
},
"settings": "Settings",
"@settings": {
"description": "Settings navigation button"
},
"iGotDistracted": "I got distracted",
"@iGotDistracted": {
"description": "Main distraction button text"
},
"pause": "Pause",
"resume": "Resume",
"stopSession": "Stop session",
"whatPulledYouAway": "What pulled you away?",
"@whatPulledYouAway": {
"description": "Distraction sheet title"
},
"skipThisTime": "Skip this time",
"stopEarly": "Stop early?",
"stopEarlyMessage": "That's totally fine — you still focused for {minutes} {minuteText}!",
"@stopEarlyMessage": {
"placeholders": {
"minutes": {
"type": "int"
},
"minuteText": {}
}
},
"keepGoing": "Keep going",
"yesStop": "Yes, stop",
"distractionEncouragement": "It happens. Let's gently come back.",
"@distractionEncouragement": {
"description": "Encouragement message when distracted"
},
"focusComplete": "Focus session complete!",
"youFocusedFor": "You focused for",
"totalToday": "Total today: {minutes} mins",
"@totalToday": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"distractionsCount": "Distractions: {count} {times}",
"@distractionsCount": {
"placeholders": {
"count": {
"type": "int"
},
"times": {}
}
},
"times": "{count, plural, =1{time} other{times}}",
"@times": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"startAnother": "Start Another",
"viewHistory": "View History",
"yourFocusJourney": "Your Focus Journey",
"noFocusSessionsYet": "No focus sessions yet",
"startFirstSession": "Start your first session\nto see your progress here!",
"today": "Today",
"sessions": "{count, plural, =1{session} other{sessions}}",
"@sessions": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"completed": "Completed",
"stoppedEarly": "Stopped early",
"distractions": "{count, plural, =1{distraction} other{distractions}}",
"@distractions": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"focusSettings": "Focus Settings",
"defaultFocusDuration": "Default Focus Duration",
"defaultLabel": "Default",
"about": "About",
"privacyPolicy": "Privacy Policy",
"aboutFocusBuddy": "About FocusBuddy",
"resetOnboarding": "Reset Onboarding",
"version": "Version 1.0.0 (MVP)",
"privacyPolicyTitle": "Privacy Policy",
"privacyPolicyContent": "FocusBuddy is 100% offline. We do not collect your name, email, location, or usage data. All sessions stay on your device.\n\nThere is no cloud sync, no account system, and no analytics tracking.\n\nFor the full privacy policy, visit:\n[Your website URL]/privacy",
"close": "Close",
"aboutTitle": "About FocusBuddy",
"aboutSubtitle": "A gentle focus timer for neurodivergent minds",
"aboutQuote": "\"Focus is not about never getting distracted — it's about gently coming back every time you do.\"",
"aboutFeatures": "✨ No punishment for distractions\n💚 Encouragement over criticism\n🔒 100% offline and private\n🌱 Made with care",
"resetOnboardingTitle": "Reset Onboarding?",
"resetOnboardingMessage": "This will show the onboarding screens again when you restart the app.",
"cancel": "Cancel",
"reset": "Reset",
"onboardingReset": "Onboarding reset. Restart the app to see it again.",
"onboarding1Title": "Focus without guilt",
"onboarding1Description": "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.",
"onboarding2Title": "Tap when you get distracted",
"onboarding2Description": "We'll gently remind you to come back.\n\nNo shame. No stress. Just a friendly nudge.",
"onboarding3Title": "Track your progress",
"onboarding3Description": "See how you're improving, one session at a time.\n\nEvery distraction is just data — not failure.",
"skip": "Skip",
"next": "Next",
"getStarted": "Get Started",
"notificationFocusInProgress": "Focus session in progress",
"notificationRemaining": "{time} remaining",
"@notificationRemaining": {
"placeholders": {
"time": {
"type": "String"
}
}
},
"notificationFocusCompleteTitle": "🎉 Focus session complete!",
"notificationFocusCompleteBodyNoDistractions": "You focused for {minutes} {minuteText} without distractions!",
"@notificationFocusCompleteBodyNoDistractions": {
"placeholders": {
"minutes": {
"type": "int"
},
"minuteText": {}
}
},
"notificationFocusCompleteBody": "You focused for {minutes} {minuteText}. Great effort!",
"@notificationFocusCompleteBody": {
"placeholders": {
"minutes": {
"type": "int"
},
"minuteText": {}
}
},
"distractionPhoneNotification": "Phone / Notification",
"distractionSocialMedia": "Social Media",
"distractionThoughts": "Thoughts / Daydream",
"distractionOther": "Other",
"language": "Language",
"selectLanguage": "Select Language",
"english": "English",
"chinese": "中文 (Chinese)"
}

109
lib/l10n/app_zh.arb Normal file
View File

@@ -0,0 +1,109 @@
{
"@@locale": "zh",
"appTitle": "专注伙伴",
"startFocusing": "开始专注",
"minutes": "{count, plural, =1{分钟} other{分钟}}",
"minutesValue": "{count} {minutes}",
"tapDistractionAnytime": "随时点击'我分心了'\n——没有负罪感。",
"history": "历史",
"settings": "设置",
"iGotDistracted": "我分心了",
"pause": "暂停",
"resume": "继续",
"stopSession": "停止会话",
"whatPulledYouAway": "是什么分散了你的注意力?",
"skipThisTime": "跳过",
"stopEarly": "提前停止?",
"stopEarlyMessage": "完全没问题——你已经专注了 {minutes} {minuteText}",
"keepGoing": "继续",
"yesStop": "确定停止",
"distractionEncouragement": "没关系,让我们温柔地回到正轨。",
"focusComplete": "专注完成!",
"youFocusedFor": "你专注了",
"totalToday": "今日总计:{minutes} 分钟",
"distractionsCount": "分心:{count} {times}",
"times": "{count, plural, =1{次} other{次}}",
"startAnother": "再来一次",
"viewHistory": "查看历史",
"yourFocusJourney": "你的专注之旅",
"noFocusSessionsYet": "还没有专注记录",
"startFirstSession": "开始你的第一次专注\n在这里查看进度",
"today": "今天",
"sessions": "{count, plural, =1{次会话} other{次会话}}",
"completed": "已完成",
"stoppedEarly": "提前停止",
"distractions": "{count, plural, =1{次分心} other{次分心}}",
"focusSettings": "专注设置",
"defaultFocusDuration": "默认专注时长",
"defaultLabel": "默认",
"about": "关于",
"privacyPolicy": "隐私政策",
"aboutFocusBuddy": "关于专注伙伴",
"resetOnboarding": "重置引导",
"version": "版本 1.0.0 (MVP)",
"privacyPolicyTitle": "隐私政策",
"privacyPolicyContent": "专注伙伴 100% 离线运行。我们不收集您的姓名、电子邮件、位置或使用数据。所有会话数据都保存在您的设备上。\n\n没有云同步没有账户系统没有分析追踪。\n\n完整隐私政策请访问\n[您的网站 URL]/privacy",
"close": "关闭",
"aboutTitle": "关于专注伙伴",
"aboutSubtitle": "为神经多样性人群设计的温柔专注计时器",
"aboutQuote": "\"专注不是永不分心——而是每次分心后温柔地回来。\"",
"aboutFeatures": "✨ 不惩罚分心\n💚 鼓励而非批评\n🔒 100% 离线和私密\n🌱 用心制作",
"resetOnboardingTitle": "重置引导?",
"resetOnboardingMessage": "重启应用后将再次显示引导页面。",
"cancel": "取消",
"reset": "重置",
"onboardingReset": "引导已重置。重启应用后将再次显示。",
"onboarding1Title": "无负罪感地专注",
"onboarding1Description": "这个应用与众不同——它不会因为你失去专注而惩罚你。\n\n完美适合 ADHD、焦虑症患者或任何觉得传统计时器太苛刻的人。",
"onboarding2Title": "分心时轻触按钮",
"onboarding2Description": "我们会温柔地提醒你回来。\n\n没有羞愧。没有压力。只是友好的提醒。",
"onboarding3Title": "追踪你的进步",
"onboarding3Description": "看看你是如何一次次进步的。\n\n每次分心都只是数据——而非失败。",
"skip": "跳过",
"next": "下一步",
"getStarted": "开始使用",
"notificationFocusInProgress": "专注进行中",
"notificationRemaining": "剩余 {time}",
"notificationFocusCompleteTitle": "🎉 专注完成!",
"notificationFocusCompleteBodyNoDistractions": "你专注了 {minutes} {minuteText},没有分心!",
"notificationFocusCompleteBody": "你专注了 {minutes} {minuteText}。做得很棒!",
"distractionPhoneNotification": "手机/通知",
"distractionSocialMedia": "社交媒体",
"distractionThoughts": "思绪/白日梦",
"distractionOther": "其他",
"language": "语言",
"selectLanguage": "选择语言",
"english": "English",
"chinese": "中文"
}

View File

@@ -1,10 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'l10n/app_localizations.dart';
import 'theme/app_theme.dart'; import 'theme/app_theme.dart';
import 'services/storage_service.dart'; 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'; import 'screens/onboarding_screen.dart';
import 'screens/settings_screen.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -39,11 +42,13 @@ class MyApp extends StatefulWidget {
class _MyAppState extends State<MyApp> { class _MyAppState extends State<MyApp> {
bool _hasCompletedOnboarding = false; bool _hasCompletedOnboarding = false;
bool _isLoading = true; bool _isLoading = true;
Locale? _locale;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_checkOnboardingStatus(); _checkOnboardingStatus();
_loadSavedLocale();
} }
Future<void> _checkOnboardingStatus() async { Future<void> _checkOnboardingStatus() async {
@@ -54,12 +59,32 @@ class _MyAppState extends State<MyApp> {
}); });
} }
Future<void> _loadSavedLocale() async {
final savedLocale = await SettingsScreen.getSavedLocale();
if (savedLocale != null) {
setState(() {
_locale = Locale(savedLocale);
});
}
}
@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,
locale: _locale,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [
Locale('en'),
Locale('zh'),
],
home: _isLoading home: _isLoading
? const Scaffold( ? const Scaffold(
body: Center( body: Center(

View File

@@ -1,21 +1,22 @@
/// Predefined distraction types /// Predefined distraction types
class DistractionType { class DistractionType {
static const scrollingSocialMedia = 'scrolling_social_media'; static const phoneNotification = 'phone_notification';
static const gotInterrupted = 'got_interrupted'; static const socialMedia = 'social_media';
static const feltOverwhelmed = 'felt_overwhelmed'; static const thoughts = 'thoughts';
static const justZonedOut = 'just_zoned_out'; static const other = 'other';
/// Get display name for a distraction type /// Get display name for a distraction type (returns key for localization)
static String getDisplayName(String type) { static String getDisplayName(String type) {
// Returns the localization key - actual translation done in UI
switch (type) { switch (type) {
case scrollingSocialMedia: case phoneNotification:
return 'Scrolling social media'; return 'Phone / Notification';
case gotInterrupted: case socialMedia:
return 'Got interrupted'; return 'Social Media';
case feltOverwhelmed: case thoughts:
return 'Felt overwhelmed'; return 'Thoughts / Daydream';
case justZonedOut: case other:
return 'Just zoned out'; return 'Other';
default: default:
return 'Unknown'; return 'Unknown';
} }
@@ -24,14 +25,14 @@ class DistractionType {
/// Get emoji for a distraction type /// Get emoji for a distraction type
static String getEmoji(String type) { static String getEmoji(String type) {
switch (type) { switch (type) {
case scrollingSocialMedia: case phoneNotification:
return '📱'; return '📱';
case gotInterrupted: case socialMedia:
return '👥'; return '💬';
case feltOverwhelmed: case thoughts:
return '😰';
case justZonedOut:
return '💭'; return '💭';
case other:
return '🌟';
default: default:
return ''; return '';
} }
@@ -39,9 +40,9 @@ class DistractionType {
/// Get all distraction types /// Get all distraction types
static List<String> get all => [ static List<String> get all => [
scrollingSocialMedia, phoneNotification,
gotInterrupted, socialMedia,
feltOverwhelmed, thoughts,
justZonedOut, other,
]; ];
} }

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../l10n/app_localizations.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import '../theme/app_text_styles.dart'; import '../theme/app_text_styles.dart';
import '../services/storage_service.dart'; import '../services/storage_service.dart';
@@ -21,6 +22,7 @@ class CompleteScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final storageService = StorageService(); final storageService = StorageService();
final todayTotal = storageService.getTodayTotalMinutes(); final todayTotal = storageService.getTodayTotalMinutes();
final todayDistractions = storageService.getTodayDistractionCount(); final todayDistractions = storageService.getTodayDistractionCount();
@@ -44,12 +46,12 @@ class CompleteScreen extends StatelessWidget {
// You focused for X minutes // You focused for X minutes
Text( Text(
'You focused for', l10n.youFocusedFor,
style: AppTextStyles.headline, style: AppTextStyles.headline,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'$focusedMinutes ${focusedMinutes == 1 ? 'minute' : 'minutes'}', l10n.minutesValue(focusedMinutes, l10n.minutes(focusedMinutes)),
style: AppTextStyles.largeNumber, style: AppTextStyles.largeNumber,
), ),
@@ -67,12 +69,12 @@ class CompleteScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Total Today: $todayTotal mins', l10n.totalToday(todayTotal),
style: AppTextStyles.bodyText, style: AppTextStyles.bodyText,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
'Distractions: $todayDistractions ${todayDistractions == 1 ? 'time' : 'times'}', l10n.distractionsCount(todayDistractions, l10n.times(todayDistractions)),
style: AppTextStyles.bodyText, style: AppTextStyles.bodyText,
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
@@ -101,7 +103,7 @@ class CompleteScreen extends StatelessWidget {
(route) => false, (route) => false,
); );
}, },
child: const Text('Start Another'), child: Text(l10n.startAnother),
), ),
), ),
@@ -118,7 +120,7 @@ class CompleteScreen extends StatelessWidget {
(route) => route.isFirst, // Keep only the home screen in stack (route) => route.isFirst, // Keep only the home screen in stack
); );
}, },
child: const Text('View History'), child: Text(l10n.viewHistory),
), ),
], ],
), ),

View File

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../l10n/app_localizations.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import '../theme/app_text_styles.dart'; import '../theme/app_text_styles.dart';
import '../models/distraction_type.dart'; import '../models/distraction_type.dart';
@@ -76,11 +77,15 @@ class _FocusScreenState extends State<FocusScreen> with WidgetsBindingObserver {
} }
void _showBackgroundNotification() { void _showBackgroundNotification() {
final l10n = AppLocalizations.of(context)!;
final minutes = _remainingSeconds ~/ 60; final minutes = _remainingSeconds ~/ 60;
final seconds = _remainingSeconds % 60; final seconds = _remainingSeconds % 60;
final timeStr = '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
_notificationService.showOngoingFocusNotification( _notificationService.showOngoingFocusNotification(
remainingMinutes: minutes, remainingMinutes: minutes,
remainingSeconds: seconds, remainingSeconds: seconds,
title: l10n.notificationFocusInProgress,
timeRemainingText: l10n.notificationRemaining(timeStr),
); );
} }
@@ -94,11 +99,15 @@ class _FocusScreenState extends State<FocusScreen> with WidgetsBindingObserver {
// Update background notification every 30 seconds when in background // Update background notification every 30 seconds when in background
if (_isInBackground && _remainingSeconds > 0) { if (_isInBackground && _remainingSeconds > 0) {
if (_remainingSeconds % 30 == 0) { if (_remainingSeconds % 30 == 0) {
final l10n = AppLocalizations.of(context)!;
final minutes = _remainingSeconds ~/ 60; final minutes = _remainingSeconds ~/ 60;
final seconds = _remainingSeconds % 60; final seconds = _remainingSeconds % 60;
final timeStr = '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
_notificationService.updateOngoingFocusNotification( _notificationService.updateOngoingFocusNotification(
remainingMinutes: minutes, remainingMinutes: minutes,
remainingSeconds: seconds, remainingSeconds: seconds,
title: l10n.notificationFocusInProgress,
timeRemainingText: l10n.notificationRemaining(timeStr),
); );
} }
} }
@@ -118,10 +127,26 @@ class _FocusScreenState extends State<FocusScreen> with WidgetsBindingObserver {
_saveFocusSession(completed: true); _saveFocusSession(completed: true);
// Send completion notification if (!mounted) return;
// Send completion notification with localized text
final l10n = AppLocalizations.of(context)!;
final minuteText = l10n.minutes(widget.durationMinutes);
final notificationBody = _distractions.isEmpty
? l10n.notificationFocusCompleteBodyNoDistractions(
widget.durationMinutes,
minuteText,
)
: l10n.notificationFocusCompleteBody(
widget.durationMinutes,
minuteText,
);
await _notificationService.showFocusCompletedNotification( await _notificationService.showFocusCompletedNotification(
minutes: widget.durationMinutes, minutes: widget.durationMinutes,
distractionCount: _distractions.length, distractionCount: _distractions.length,
title: l10n.notificationFocusCompleteTitle,
body: notificationBody,
); );
if (!mounted) return; if (!mounted) return;
@@ -152,20 +177,22 @@ class _FocusScreenState extends State<FocusScreen> with WidgetsBindingObserver {
} }
void _stopEarly() { void _stopEarly() {
final l10n = AppLocalizations.of(context)!;
final actualMinutes = ((widget.durationMinutes * 60 - _remainingSeconds) / 60).floor(); final actualMinutes = ((widget.durationMinutes * 60 - _remainingSeconds) / 60).floor();
final minuteText = actualMinutes == 1 ? l10n.minutes(1) : l10n.minutes(actualMinutes);
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Stop early?'), title: Text(l10n.stopEarly),
content: Text( content: Text(
"That's totally fine — you still focused for $actualMinutes ${actualMinutes == 1 ? 'minute' : 'minutes'}!", l10n.stopEarlyMessage(actualMinutes, minuteText),
style: AppTextStyles.bodyText, style: AppTextStyles.bodyText,
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text('Keep going'), child: Text(l10n.keepGoing),
), ),
TextButton( TextButton(
onPressed: () { onPressed: () {
@@ -184,7 +211,7 @@ class _FocusScreenState extends State<FocusScreen> with WidgetsBindingObserver {
), ),
); );
}, },
child: const Text('Yes, stop'), child: Text(l10n.yesStop),
), ),
], ],
), ),
@@ -210,6 +237,16 @@ class _FocusScreenState extends State<FocusScreen> with WidgetsBindingObserver {
} }
void _showDistractionSheet() { void _showDistractionSheet() {
final l10n = AppLocalizations.of(context)!;
// Map distraction types to translations
final distractionOptions = [
(type: DistractionType.phoneNotification, label: l10n.distractionPhoneNotification),
(type: DistractionType.socialMedia, label: l10n.distractionSocialMedia),
(type: DistractionType.thoughts, label: l10n.distractionThoughts),
(type: DistractionType.other, label: l10n.distractionOther),
];
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
backgroundColor: AppColors.white, backgroundColor: AppColors.white,
@@ -245,9 +282,9 @@ class _FocusScreenState extends State<FocusScreen> with WidgetsBindingObserver {
const SizedBox(height: 24), const SizedBox(height: 24),
// Title // Title
const Text( Text(
'What pulled you away?', l10n.whatPulledYouAway,
style: TextStyle( style: const TextStyle(
fontFamily: 'Nunito', fontFamily: 'Nunito',
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -257,24 +294,24 @@ class _FocusScreenState extends State<FocusScreen> with WidgetsBindingObserver {
const SizedBox(height: 24), const SizedBox(height: 24),
// Distraction options // Distraction options
...DistractionType.all.map((type) { ...distractionOptions.map((option) {
return Column( return Column(
children: [ children: [
ListTile( ListTile(
leading: Text( leading: Text(
DistractionType.getEmoji(type), DistractionType.getEmoji(option.type),
style: const TextStyle(fontSize: 24), style: const TextStyle(fontSize: 24),
), ),
title: Text( title: Text(
DistractionType.getDisplayName(type), option.label,
style: AppTextStyles.bodyText, style: AppTextStyles.bodyText,
), ),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
_recordDistraction(type); _recordDistraction(option.type);
}, },
), ),
if (type != DistractionType.all.last) if (option != distractionOptions.last)
const Divider(color: AppColors.divider), const Divider(color: AppColors.divider),
], ],
); );
@@ -289,7 +326,7 @@ class _FocusScreenState extends State<FocusScreen> with WidgetsBindingObserver {
Navigator.pop(context); Navigator.pop(context);
_recordDistraction(null); _recordDistraction(null);
}, },
child: const Text('Skip this time'), child: Text(l10n.skipThisTime),
), ),
), ),
], ],
@@ -302,6 +339,8 @@ class _FocusScreenState extends State<FocusScreen> with WidgetsBindingObserver {
} }
void _recordDistraction(String? type) { void _recordDistraction(String? type) {
final l10n = AppLocalizations.of(context)!;
setState(() { setState(() {
if (type != null) { if (type != null) {
_distractions.add(type); _distractions.add(type);
@@ -310,9 +349,9 @@ class _FocusScreenState extends State<FocusScreen> with WidgetsBindingObserver {
// Show encouragement toast // Show encouragement toast
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text("It happens. Let's gently come back."), content: Text(l10n.distractionEncouragement),
duration: Duration(seconds: 2), duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
), ),
); );
@@ -326,6 +365,8 @@ class _FocusScreenState extends State<FocusScreen> with WidgetsBindingObserver {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold( return Scaffold(
backgroundColor: AppColors.background, backgroundColor: AppColors.background,
body: SafeArea( body: SafeArea(
@@ -365,18 +406,18 @@ class _FocusScreenState extends State<FocusScreen> with WidgetsBindingObserver {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Text( Text(
'I got distracted', l10n.iGotDistracted,
style: TextStyle( style: const TextStyle(
fontFamily: 'Nunito', fontFamily: 'Nunito',
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( const Text(
'🤚', '🤚',
style: const TextStyle(fontSize: 20), style: TextStyle(fontSize: 20),
), ),
], ],
), ),
@@ -403,7 +444,7 @@ class _FocusScreenState extends State<FocusScreen> with WidgetsBindingObserver {
children: [ children: [
Icon(_isPaused ? Icons.play_arrow : Icons.pause), Icon(_isPaused ? Icons.play_arrow : Icons.pause),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(_isPaused ? 'Resume' : 'Pause'), Text(_isPaused ? l10n.resume : l10n.pause),
], ],
), ),
), ),
@@ -422,9 +463,9 @@ class _FocusScreenState extends State<FocusScreen> with WidgetsBindingObserver {
padding: const EdgeInsets.only(bottom: 24.0), padding: const EdgeInsets.only(bottom: 24.0),
child: TextButton( child: TextButton(
onPressed: _stopEarly, onPressed: _stopEarly,
child: const Text( child: Text(
'Stop session', l10n.stopSession,
style: TextStyle( style: const TextStyle(
color: AppColors.textSecondary, color: AppColors.textSecondary,
fontSize: 14, fontSize: 14,
), ),

View File

@@ -4,6 +4,7 @@ import '../theme/app_text_styles.dart';
import '../models/focus_session.dart'; import '../models/focus_session.dart';
import '../services/storage_service.dart'; import '../services/storage_service.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../l10n/app_localizations.dart';
/// History Screen - Shows past focus sessions /// History Screen - Shows past focus sessions
class HistoryScreen extends StatefulWidget { class HistoryScreen extends StatefulWidget {
@@ -18,6 +19,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final sessions = _storageService.getAllSessions(); final sessions = _storageService.getAllSessions();
final todayTotal = _storageService.getTodayTotalMinutes(); final todayTotal = _storageService.getTodayTotalMinutes();
final todayDistractions = _storageService.getTodayDistractionCount(); final todayDistractions = _storageService.getTodayDistractionCount();
@@ -44,16 +46,17 @@ class _HistoryScreenState extends State<HistoryScreen> {
return Scaffold( return Scaffold(
backgroundColor: AppColors.background, backgroundColor: AppColors.background,
appBar: AppBar( appBar: AppBar(
title: const Text('Your Focus Journey'), title: Text(l10n.yourFocusJourney),
backgroundColor: AppColors.background, backgroundColor: AppColors.background,
), ),
body: sessions.isEmpty body: sessions.isEmpty
? _buildEmptyState(context) ? _buildEmptyState(context, l10n)
: ListView( : ListView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
children: [ children: [
// Today's Summary Card // Today's Summary Card
_buildTodaySummary( _buildTodaySummary(
l10n,
todayTotal, todayTotal,
todayDistractions, todayDistractions,
todayCompleted, todayCompleted,
@@ -64,14 +67,14 @@ class _HistoryScreenState extends State<HistoryScreen> {
// Sessions by date // Sessions by date
...sortedDates.map((date) { ...sortedDates.map((date) {
final dateSessions = sessionsByDate[date]!; final dateSessions = sessionsByDate[date]!;
return _buildDateSection(date, dateSessions); return _buildDateSection(l10n, date, dateSessions);
}), }),
], ],
), ),
); );
} }
Widget _buildEmptyState(BuildContext context) { Widget _buildEmptyState(BuildContext context, AppLocalizations l10n) {
return Center( return Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(32.0), padding: const EdgeInsets.all(32.0),
@@ -84,13 +87,13 @@ class _HistoryScreenState extends State<HistoryScreen> {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
'No focus sessions yet', l10n.noFocusSessionsYet,
style: AppTextStyles.headline, style: AppTextStyles.headline,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
'Start your first session\nto see your progress here!', l10n.startFirstSession,
style: AppTextStyles.helperText, style: AppTextStyles.helperText,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
@@ -105,7 +108,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
); );
} }
Widget _buildTodaySummary(int totalMins, int distractions, int completed) { Widget _buildTodaySummary(AppLocalizations l10n, int totalMins, int distractions, int completed) {
return Container( return Container(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -117,9 +120,9 @@ class _HistoryScreenState extends State<HistoryScreen> {
children: [ children: [
Row( Row(
children: [ children: [
const Text( Text(
'📅 Today', '📅 ${l10n.today}',
style: TextStyle( style: const TextStyle(
fontFamily: 'Nunito', fontFamily: 'Nunito',
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -137,7 +140,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Text( child: Text(
'$completed ${completed == 1 ? 'session' : 'sessions'}', l10n.sessions(completed),
style: const TextStyle( style: const TextStyle(
fontFamily: 'Nunito', fontFamily: 'Nunito',
fontSize: 14, fontSize: 14,
@@ -152,13 +155,13 @@ class _HistoryScreenState extends State<HistoryScreen> {
Row( Row(
children: [ children: [
Expanded( Expanded(
child: _buildStat('Total', '$totalMins mins', '⏱️'), child: _buildStat('Total', l10n.minutesValue(totalMins, l10n.minutes(totalMins)), '⏱️'),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: _buildStat( child: _buildStat(
'Distractions', 'Distractions',
'$distractions ${distractions == 1 ? 'time' : 'times'}', l10n.distractionsCount(distractions, l10n.times(distractions)),
'🤚', '🤚',
), ),
), ),
@@ -201,10 +204,10 @@ class _HistoryScreenState extends State<HistoryScreen> {
); );
} }
Widget _buildDateSection(DateTime date, List<FocusSession> sessions) { Widget _buildDateSection(AppLocalizations l10n, DateTime date, List<FocusSession> sessions) {
final isToday = _isToday(date); final isToday = _isToday(date);
final dateLabel = isToday final dateLabel = isToday
? 'Today' ? l10n.today
: DateFormat('EEE, MMM d').format(date); : DateFormat('EEE, MMM d').format(date);
final totalMinutes = sessions.fold<int>( final totalMinutes = sessions.fold<int>(
@@ -231,7 +234,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Text( Text(
'($totalMinutes mins)', '(${l10n.minutesValue(totalMinutes, l10n.minutes(totalMinutes))})',
style: const TextStyle( style: const TextStyle(
fontFamily: 'Nunito', fontFamily: 'Nunito',
fontSize: 16, fontSize: 16,
@@ -244,15 +247,15 @@ class _HistoryScreenState extends State<HistoryScreen> {
), ),
// Session cards // Session cards
...sessions.map((session) => _buildSessionCard(session)), ...sessions.map((session) => _buildSessionCard(l10n, session)),
], ],
); );
} }
Widget _buildSessionCard(FocusSession session) { Widget _buildSessionCard(AppLocalizations l10n, FocusSession session) {
final timeStr = DateFormat('HH:mm').format(session.startTime); final timeStr = DateFormat('HH:mm').format(session.startTime);
final statusEmoji = session.completed ? '' : '⏸️'; final statusEmoji = session.completed ? '' : '⏸️';
final statusText = session.completed ? 'Completed' : 'Stopped early'; final statusText = session.completed ? l10n.completed : l10n.stoppedEarly;
return Container( return Container(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
@@ -286,14 +289,14 @@ class _HistoryScreenState extends State<HistoryScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'${session.actualMinutes} ${session.actualMinutes == 1 ? 'minute' : 'minutes'}', l10n.minutesValue(session.actualMinutes, l10n.minutes(session.actualMinutes)),
style: AppTextStyles.bodyText, style: AppTextStyles.bodyText,
), ),
if (session.distractionCount > 0) if (session.distractionCount > 0)
Padding( Padding(
padding: const EdgeInsets.only(top: 4), padding: const EdgeInsets.only(top: 4),
child: Text( child: Text(
'🤚 ${session.distractionCount} ${session.distractionCount == 1 ? 'distraction' : 'distractions'}', l10n.distractionsCount(session.distractionCount, l10n.times(session.distractionCount)),
style: const TextStyle( style: const TextStyle(
fontFamily: 'Nunito', fontFamily: 'Nunito',
fontSize: 14, fontSize: 14,

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../l10n/app_localizations.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import '../theme/app_text_styles.dart'; import '../theme/app_text_styles.dart';
import '../services/encouragement_service.dart'; import '../services/encouragement_service.dart';
@@ -37,6 +38,8 @@ class _HomeScreenState extends State<HomeScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold( return Scaffold(
backgroundColor: AppColors.background, backgroundColor: AppColors.background,
body: SafeArea( body: SafeArea(
@@ -47,7 +50,7 @@ class _HomeScreenState extends State<HomeScreen> {
children: [ children: [
// App Title // App Title
Text( Text(
'FocusBuddy', l10n.appTitle,
style: AppTextStyles.appTitle, style: AppTextStyles.appTitle,
), ),
@@ -64,7 +67,7 @@ class _HomeScreenState extends State<HomeScreen> {
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: Text( child: Text(
'$_defaultDuration minutes', l10n.minutesValue(_defaultDuration, l10n.minutes(_defaultDuration)),
style: const TextStyle( style: const TextStyle(
fontFamily: 'Nunito', fontFamily: 'Nunito',
fontSize: 28, fontSize: 28,
@@ -98,7 +101,7 @@ class _HomeScreenState extends State<HomeScreen> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Text('Start Focusing'), Text(l10n.startFocusing),
const SizedBox(width: 8), const SizedBox(width: 8),
Icon( Icon(
Icons.play_arrow, Icons.play_arrow,
@@ -113,7 +116,7 @@ class _HomeScreenState extends State<HomeScreen> {
// Helper Text // Helper Text
Text( Text(
"Tap 'I got distracted'\nanytime — no guilt.", l10n.tapDistractionAnytime,
style: AppTextStyles.helperText, style: AppTextStyles.helperText,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
@@ -134,7 +137,7 @@ class _HomeScreenState extends State<HomeScreen> {
); );
}, },
icon: const Icon(Icons.bar_chart), icon: const Icon(Icons.bar_chart),
label: const Text('History'), label: Text(l10n.history),
), ),
TextButton.icon( TextButton.icon(
onPressed: () async { onPressed: () async {
@@ -148,7 +151,7 @@ class _HomeScreenState extends State<HomeScreen> {
_loadDefaultDuration(); _loadDefaultDuration();
}, },
icon: const Icon(Icons.settings), icon: const Icon(Icons.settings),
label: const Text('Settings'), label: Text(l10n.settings),
), ),
], ],
), ),

View File

@@ -4,6 +4,7 @@ import '../theme/app_colors.dart';
import '../theme/app_text_styles.dart'; import '../theme/app_text_styles.dart';
import 'home_screen.dart'; import 'home_screen.dart';
import '../services/encouragement_service.dart'; import '../services/encouragement_service.dart';
import '../l10n/app_localizations.dart';
/// Onboarding Screen - Shows on first launch /// Onboarding Screen - Shows on first launch
class OnboardingScreen extends StatefulWidget { class OnboardingScreen extends StatefulWidget {
@@ -35,27 +36,7 @@ class OnboardingScreen extends StatefulWidget {
class _OnboardingScreenState extends State<OnboardingScreen> { class _OnboardingScreenState extends State<OnboardingScreen> {
final PageController _pageController = PageController(); final PageController _pageController = PageController();
int _currentPage = 0; int _currentPage = 0;
static const int _totalPages = 3;
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) { void _onPageChanged(int page) {
setState(() { setState(() {
@@ -64,7 +45,7 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
} }
void _nextPage() { void _nextPage() {
if (_currentPage < _pages.length - 1) { if (_currentPage < _totalPages - 1) {
_pageController.animateToPage( _pageController.animateToPage(
_currentPage + 1, _currentPage + 1,
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
@@ -102,6 +83,26 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final pages = [
OnboardingPage(
emoji: '💚',
title: l10n.onboarding1Title,
description: l10n.onboarding1Description,
),
OnboardingPage(
emoji: '🤚',
title: l10n.onboarding2Title,
description: l10n.onboarding2Description,
),
OnboardingPage(
emoji: '📊',
title: l10n.onboarding3Title,
description: l10n.onboarding3Description,
),
];
return Scaffold( return Scaffold(
backgroundColor: AppColors.background, backgroundColor: AppColors.background,
body: SafeArea( body: SafeArea(
@@ -114,9 +115,9 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: TextButton( child: TextButton(
onPressed: _skipOnboarding, onPressed: _skipOnboarding,
child: const Text( child: Text(
'Skip', l10n.skip,
style: TextStyle( style: const TextStyle(
fontFamily: 'Nunito', fontFamily: 'Nunito',
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -132,9 +133,9 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
child: PageView.builder( child: PageView.builder(
controller: _pageController, controller: _pageController,
onPageChanged: _onPageChanged, onPageChanged: _onPageChanged,
itemCount: _pages.length, itemCount: pages.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return _buildPage(_pages[index]); return _buildPage(pages[index]);
}, },
), ),
), ),
@@ -145,7 +146,7 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: List.generate( children: List.generate(
_pages.length, pages.length,
(index) => _buildIndicator(index == _currentPage), (index) => _buildIndicator(index == _currentPage),
), ),
), ),
@@ -159,9 +160,9 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
child: ElevatedButton( child: ElevatedButton(
onPressed: _nextPage, onPressed: _nextPage,
child: Text( child: Text(
_currentPage == _pages.length - 1 _currentPage == pages.length - 1
? 'Get Started' ? l10n.getStarted
: 'Next', : l10n.next,
), ),
), ),
), ),

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../l10n/app_localizations.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import '../theme/app_text_styles.dart'; import '../theme/app_text_styles.dart';
@@ -13,7 +14,20 @@ class SettingsScreen extends StatefulWidget {
return prefs.getInt(_durationKey) ?? 25; return prefs.getInt(_durationKey) ?? 25;
} }
/// Get the saved locale
static Future<String?> getSavedLocale() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_localeKey);
}
/// Save the locale
static Future<void> saveLocale(String localeCode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_localeKey, localeCode);
}
static const String _durationKey = 'default_duration'; static const String _durationKey = 'default_duration';
static const String _localeKey = 'app_locale';
@override @override
State<SettingsScreen> createState() => _SettingsScreenState(); State<SettingsScreen> createState() => _SettingsScreenState();
@@ -21,6 +35,7 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> { class _SettingsScreenState extends State<SettingsScreen> {
int _selectedDuration = 25; // Default int _selectedDuration = 25; // Default
String _selectedLocale = 'en'; // Default
final List<int> _durationOptions = [15, 25, 45]; final List<int> _durationOptions = [15, 25, 45];
@@ -28,6 +43,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_loadSavedDuration(); _loadSavedDuration();
_loadSavedLocale();
} }
Future<void> _loadSavedDuration() async { Future<void> _loadSavedDuration() async {
@@ -37,6 +53,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
}); });
} }
Future<void> _loadSavedLocale() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
_selectedLocale = prefs.getString(SettingsScreen._localeKey) ?? 'en';
});
}
Future<void> _saveDuration(int duration) async { Future<void> _saveDuration(int duration) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setInt(SettingsScreen._durationKey, duration); await prefs.setInt(SettingsScreen._durationKey, duration);
@@ -45,12 +68,30 @@ class _SettingsScreenState extends State<SettingsScreen> {
}); });
} }
Future<void> _saveLocale(String localeCode) async {
await SettingsScreen.saveLocale(localeCode);
setState(() {
_selectedLocale = localeCode;
});
// Show snackbar to inform user to restart
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.onboardingReset),
duration: const Duration(seconds: 2),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold( return Scaffold(
backgroundColor: AppColors.background, backgroundColor: AppColors.background,
appBar: AppBar( appBar: AppBar(
title: const Text('Settings'), title: Text(l10n.settings),
backgroundColor: AppColors.background, backgroundColor: AppColors.background,
), ),
body: ListView( body: ListView(
@@ -58,31 +99,43 @@ class _SettingsScreenState extends State<SettingsScreen> {
children: [ children: [
// Focus Duration Section // Focus Duration Section
_buildSection( _buildSection(
title: 'Focus Settings', title: l10n.focusSettings,
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.only(bottom: 16),
child: Text( child: Text(
'Default Focus Duration', l10n.defaultFocusDuration,
style: AppTextStyles.bodyText, style: AppTextStyles.bodyText,
), ),
), ),
..._durationOptions.map((duration) { ..._durationOptions.map((duration) {
return _buildDurationOption(duration); return _buildDurationOption(l10n, duration);
}), }),
], ],
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
// Language Section
_buildSection(
title: l10n.language,
children: [
_buildLanguageOption(l10n, 'en', l10n.english),
const Divider(color: AppColors.divider),
_buildLanguageOption(l10n, 'zh', l10n.chinese),
],
),
const SizedBox(height: 32),
// About Section // About Section
_buildSection( _buildSection(
title: 'About', title: l10n.about,
children: [ children: [
ListTile( ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: Text( title: Text(
'Privacy Policy', l10n.privacyPolicy,
style: AppTextStyles.bodyText, style: AppTextStyles.bodyText,
), ),
trailing: const Icon( trailing: const Icon(
@@ -98,7 +151,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
ListTile( ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: Text( title: Text(
'About FocusBuddy', l10n.aboutFocusBuddy,
style: AppTextStyles.bodyText, style: AppTextStyles.bodyText,
), ),
trailing: const Icon( trailing: const Icon(
@@ -114,7 +167,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
ListTile( ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: Text( title: Text(
'Reset Onboarding', l10n.resetOnboarding,
style: AppTextStyles.bodyText.copyWith( style: AppTextStyles.bodyText.copyWith(
color: AppColors.textSecondary, color: AppColors.textSecondary,
), ),
@@ -136,7 +189,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Version info // Version info
Center( Center(
child: Text( child: Text(
'Version 1.0.0 (MVP)', l10n.version,
style: AppTextStyles.helperText.copyWith(fontSize: 12), style: AppTextStyles.helperText.copyWith(fontSize: 12),
), ),
), ),
@@ -174,7 +227,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
); );
} }
Widget _buildDurationOption(int duration) { Widget _buildDurationOption(AppLocalizations l10n, int duration) {
final isSelected = _selectedDuration == duration; final isSelected = _selectedDuration == duration;
return GestureDetector( return GestureDetector(
@@ -222,7 +275,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Duration text // Duration text
Expanded( Expanded(
child: Text( child: Text(
'$duration minutes', l10n.minutesValue(duration, l10n.minutes(duration)),
style: TextStyle( style: TextStyle(
fontFamily: 'Nunito', fontFamily: 'Nunito',
fontSize: 16, fontSize: 16,
@@ -245,9 +298,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
color: AppColors.success.withValues(alpha: 0.1), color: AppColors.success.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: const Text( child: Text(
'Default', l10n.defaultLabel,
style: TextStyle( style: const TextStyle(
fontFamily: 'Nunito', fontFamily: 'Nunito',
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -261,25 +314,47 @@ class _SettingsScreenState extends State<SettingsScreen> {
); );
} }
Widget _buildLanguageOption(AppLocalizations l10n, String localeCode, String label) {
final isSelected = _selectedLocale == localeCode;
return ListTile(
contentPadding: EdgeInsets.zero,
title: Text(
label,
style: TextStyle(
fontFamily: 'Nunito',
fontSize: 16,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
color: isSelected ? AppColors.primary : AppColors.textPrimary,
),
),
trailing: isSelected
? const Icon(
Icons.check_circle,
color: AppColors.primary,
)
: null,
onTap: () => _saveLocale(localeCode),
);
}
void _showPrivacyPolicy() { void _showPrivacyPolicy() {
final l10n = AppLocalizations.of(context)!;
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Privacy Policy'), title: Text(l10n.privacyPolicyTitle),
content: SingleChildScrollView( content: SingleChildScrollView(
child: Text( child: Text(
'FocusBuddy is 100% offline. We do not collect your name, email, ' l10n.privacyPolicyContent,
'location, or usage data. All sessions stay on your device.\n\n'
'There is no cloud sync, no account system, and no analytics tracking.\n\n'
'For the full privacy policy, visit:\n'
'[Your website URL]/privacy',
style: AppTextStyles.bodyText, style: AppTextStyles.bodyText,
), ),
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text('Close'), child: Text(l10n.close),
), ),
], ],
), ),
@@ -287,18 +362,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
void _showAboutDialog() { void _showAboutDialog() {
final l10n = AppLocalizations.of(context)!;
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('About FocusBuddy'), title: Text(l10n.aboutTitle),
content: SingleChildScrollView( content: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( Text(
'FocusBuddy', l10n.appTitle,
style: TextStyle( style: const TextStyle(
fontFamily: 'Nunito', fontFamily: 'Nunito',
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
@@ -307,21 +384,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'A gentle focus timer for neurodivergent minds', l10n.aboutSubtitle,
style: AppTextStyles.bodyText, style: AppTextStyles.bodyText,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'"Focus is not about never getting distracted — ' l10n.aboutQuote,
'it\'s about gently coming back every time you do."',
style: AppTextStyles.encouragementQuote, style: AppTextStyles.encouragementQuote,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'✨ No punishment for distractions\n' l10n.aboutFeatures,
'💚 Encouragement over criticism\n'
'🔒 100% offline and private\n'
'🌱 Made with care',
style: AppTextStyles.bodyText, style: AppTextStyles.bodyText,
), ),
], ],
@@ -330,7 +403,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text('Close'), child: Text(l10n.close),
), ),
], ],
), ),
@@ -338,18 +411,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
void _resetOnboarding() async { void _resetOnboarding() async {
final l10n = AppLocalizations.of(context)!;
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Reset Onboarding?'), title: Text(l10n.resetOnboardingTitle),
content: Text( content: Text(
'This will show the onboarding screens again when you restart the app.', l10n.resetOnboardingMessage,
style: AppTextStyles.bodyText, style: AppTextStyles.bodyText,
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text('Cancel'), child: Text(l10n.cancel),
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
@@ -360,13 +435,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
Navigator.pop(context); Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('Onboarding reset. Restart the app to see it again.'), content: Text(l10n.onboardingReset),
duration: Duration(seconds: 3), duration: const Duration(seconds: 3),
), ),
); );
}, },
child: const Text('Reset'), child: Text(l10n.reset),
), ),
], ],
), ),

View File

@@ -91,6 +91,8 @@ class NotificationService {
Future<void> showFocusCompletedNotification({ Future<void> showFocusCompletedNotification({
required int minutes, required int minutes,
required int distractionCount, required int distractionCount,
String? title,
String? body,
}) async { }) async {
if (kIsWeb || !_initialized) return; if (kIsWeb || !_initialized) return;
@@ -116,22 +118,23 @@ class NotificationService {
iOS: iosDetails, iOS: iosDetails,
); );
// Create notification message // Use provided title/body or fall back to English
final title = '🎉 Focus session complete!'; final notificationTitle = title ?? '🎉 Focus session complete!';
final body = distractionCount == 0 final notificationBody = body ??
(distractionCount == 0
? 'You focused for $minutes ${minutes == 1 ? 'minute' : 'minutes'} without distractions!' ? 'You focused for $minutes ${minutes == 1 ? 'minute' : 'minutes'} without distractions!'
: 'You focused for $minutes ${minutes == 1 ? 'minute' : 'minutes'}. Great effort!'; : 'You focused for $minutes ${minutes == 1 ? 'minute' : 'minutes'}. Great effort!');
await _notifications.show( await _notifications.show(
0, // Notification ID 0, // Notification ID
title, notificationTitle,
body, notificationBody,
notificationDetails, notificationDetails,
payload: 'focus_completed', payload: 'focus_completed',
); );
if (kDebugMode) { if (kDebugMode) {
print('Notification shown: $title - $body'); print('Notification shown: $notificationTitle - $notificationBody');
} }
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
@@ -207,11 +210,13 @@ class NotificationService {
Future<void> showOngoingFocusNotification({ Future<void> showOngoingFocusNotification({
required int remainingMinutes, required int remainingMinutes,
required int remainingSeconds, required int remainingSeconds,
String? title,
String? timeRemainingText,
}) async { }) async {
if (kIsWeb || !_initialized) return; if (kIsWeb || !_initialized) return;
try { try {
// Format time display // Format time display for fallback
final timeStr = '${remainingMinutes.toString().padLeft(2, '0')}:${(remainingSeconds % 60).toString().padLeft(2, '0')}'; final timeStr = '${remainingMinutes.toString().padLeft(2, '0')}:${(remainingSeconds % 60).toString().padLeft(2, '0')}';
const androidDetails = AndroidNotificationDetails( const androidDetails = AndroidNotificationDetails(
@@ -240,10 +245,14 @@ class NotificationService {
iOS: iosDetails, iOS: iosDetails,
); );
// Use provided text or fall back to English
final notificationTitle = title ?? '⏱️ Focus session in progress';
final notificationBody = timeRemainingText ?? '$timeStr remaining';
await _notifications.show( await _notifications.show(
2, // Use ID 2 for ongoing notifications 2, // Use ID 2 for ongoing notifications
'⏱️ Focus session in progress', notificationTitle,
'$timeStr remaining', notificationBody,
notificationDetails, notificationDetails,
payload: 'focus_ongoing', payload: 'focus_ongoing',
); );
@@ -258,11 +267,15 @@ class NotificationService {
Future<void> updateOngoingFocusNotification({ Future<void> updateOngoingFocusNotification({
required int remainingMinutes, required int remainingMinutes,
required int remainingSeconds, required int remainingSeconds,
String? title,
String? timeRemainingText,
}) async { }) async {
// On Android, showing the same notification ID updates it // On Android, showing the same notification ID updates it
await showOngoingFocusNotification( await showOngoingFocusNotification(
remainingMinutes: remainingMinutes, remainingMinutes: remainingMinutes,
remainingSeconds: remainingSeconds, remainingSeconds: remainingSeconds,
title: title,
timeRemainingText: timeRemainingText,
); );
} }

View File

@@ -254,6 +254,11 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "7.2.0" version: "7.2.0"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -348,10 +353,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: intl name: intl
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "0.19.0" version: "0.20.2"
io: io:
dependency: transitive dependency: transitive
description: description:

View File

@@ -30,6 +30,8 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
flutter_localizations:
sdk: flutter
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
@@ -41,7 +43,7 @@ dependencies:
flutter_local_notifications: ^17.0.0 # Notifications flutter_local_notifications: ^17.0.0 # Notifications
path_provider: ^2.1.0 # File paths path_provider: ^2.1.0 # File paths
shared_preferences: ^2.2.0 # Simple key-value storage (for onboarding) shared_preferences: ^2.2.0 # Simple key-value storage (for onboarding)
intl: ^0.19.0 # Date formatting intl: ^0.20.2 # Date formatting and i18n
google_fonts: ^6.1.0 # Google Fonts (Nunito) google_fonts: ^6.1.0 # Google Fonts (Nunito)
dev_dependencies: dev_dependencies:
@@ -63,6 +65,9 @@ dev_dependencies:
# The following section is specific to Flutter packages. # The following section is specific to Flutter packages.
flutter: flutter:
# Enable code generation for localization
generate: true
# The following line ensures that the Material Icons font is # The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in # included with your application, so that you can use the icons in
# the material Icons class. # the material Icons class.