Add a fully-featured meditation timer screen to the existing upCal Flutter app. The feature integrates seamlessly with the existing wheat/dark-blue visual identity and must work reliably with the screen off (background audio/foreground service).
In home_screen.dart, modify the AppBar to add a meditation icon on the left (leading) side that opens the meditation timer:
appBar: AppBar(
backgroundColor: _bgColor,
foregroundColor: _textColor,
surfaceTintColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.self_improvement, color: _textColor), // meditation pose icon
tooltip: 'Meditation Timer',
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const MeditationTimerScreen()),
),
),
title: Text(l.t('app_title')),
actions: [
IconButton(
icon: Icon(Icons.settings, color: _textColor),
onPressed: _navigateToSettings,
),
],
),lib/
screens/
meditation_timer_screen.dart ← main screen (timer + journey stats)
models/
meditation_session.dart ← data model for a completed session
services/
meditation_service.dart ← persistence (SharedPreferences)
meditation_audio_service.dart ← bell sounds via audioplayers
meditation_background_service.dart ← background timer (flutter_foreground_task or workmanager)
dependencies:
audioplayers: ^6.1.0 # bell/gong sounds
flutter_foreground_task: ^8.x # keeps timer alive when screen is off (iOS: background modes; Android: foreground service)
shared_preferences: ^2.x # already used — for persisting sessions & quick-time buttons
wakelock_plus: ^1.x # keeps screen on during active meditation (optional but nice)iOS Info.plist additions required:
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>Android AndroidManifest.xml additions required (inside <manifest>):
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />And inside <application>:
<service
android:name="com.pravera.flutter_foreground_task.service.ForegroundTaskService"
android:foregroundServiceType="mediaPlayback"
android:stopWithTask="false" />class MeditationSession {
final DateTime startTime; // UTC
final int durationSeconds; // actual elapsed seconds (may differ if ended early)
final int targetSeconds; // configured target
final String bellType; // e.g. 'chime', 'bowl', 'gong', 'tibetan', 'woodblock'
MeditationSession({
required this.startTime,
required this.durationSeconds,
required this.targetSeconds,
required this.bellType,
});
Map<String, dynamic> toJson() => {
'startTime': startTime.toIso8601String(),
'durationSeconds': durationSeconds,
'targetSeconds': targetSeconds,
'bellType': bellType,
};
factory MeditationSession.fromJson(Map<String, dynamic> j) => MeditationSession(
startTime: DateTime.parse(j['startTime']),
durationSeconds: j['durationSeconds'],
targetSeconds: j['targetSeconds'],
bellType: j['bellType'] ?? 'chime',
);
}Persist all sessions and quick-time presets using SharedPreferences.
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/meditation_session.dart';
class MeditationService {
static const _sessionsKey = 'meditation_sessions_v1';
static const _quickTimesKey = 'meditation_quick_times_v1';
static const _bellTypeKey = 'meditation_bell_type';
static const _intervalSecondsKey = 'meditation_interval_seconds';
static const _prepSecondsKey = 'meditation_prep_seconds';
// Default quick-time presets (seconds): early morning 1h, morning 2h, afternoon 3h, evening 1h
static const List<int> defaultQuickTimes = [3600, 7200, 10800, 3600];
static const List<String> defaultQuickTimeLabels = ['Early Morning', 'Morning', 'Afternoon', 'Evening'];
Future<List<MeditationSession>> getSessions() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getStringList(_sessionsKey) ?? [];
return raw.map((s) => MeditationSession.fromJson(jsonDecode(s))).toList()
..sort((a, b) => b.startTime.compareTo(a.startTime));
}
Future<void> saveSession(MeditationSession session) async {
final prefs = await SharedPreferences.getInstance();
final sessions = await getSessions();
sessions.insert(0, session);
// Keep last 365 sessions max
final capped = sessions.take(365).toList();
await prefs.setStringList(_sessionsKey, capped.map((s) => jsonEncode(s.toJson())).toList());
}
// Quick times: List of 4 ints (seconds each)
Future<List<int>> getQuickTimes() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getStringList(_quickTimesKey);
if (raw == null || raw.length < 4) return List.from(defaultQuickTimes);
return raw.map((s) => int.tryParse(s) ?? 3600).toList();
}
Future<void> setQuickTime(int index, int seconds) async {
final prefs = await SharedPreferences.getInstance();
final times = await getQuickTimes();
if (index >= 0 && index < times.length) {
times[index] = seconds;
await prefs.setStringList(_quickTimesKey, times.map((t) => t.toString()).toList());
}
}
// Quick time labels
Future<List<String>> getQuickTimeLabels() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getStringList('meditation_quick_labels_v1');
if (raw == null || raw.length < 4) return List.from(defaultQuickTimeLabels);
return raw;
}
Future<void> setQuickTimeLabel(int index, String label) async {
final prefs = await SharedPreferences.getInstance();
final labels = await getQuickTimeLabels();
if (index >= 0 && index < labels.length) {
labels[index] = label;
await prefs.setStringList('meditation_quick_labels_v1', labels);
}
}
Future<String> getBellType() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_bellTypeKey) ?? 'chime';
}
Future<void> setBellType(String type) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_bellTypeKey, type);
}
Future<int> getIntervalSeconds() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getInt(_intervalSecondsKey) ?? 0;
}
Future<void> setIntervalSeconds(int s) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_intervalSecondsKey, s);
}
Future<int> getPrepSeconds() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getInt(_prepSecondsKey) ?? 5;
}
Future<void> setPrepSeconds(int s) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_prepSecondsKey, s);
}
// ── Analytics helpers ───────────────────────────────────────────────
Future<int> getCurrentStreak() async {
final sessions = await getSessions();
if (sessions.isEmpty) return 0;
final today = DateTime.now();
int streak = 0;
DateTime checking = DateTime(today.year, today.month, today.day);
for (int i = 0; i <= 365; i++) {
final dayStart = checking.subtract(Duration(days: i));
final dayEnd = dayStart.add(const Duration(days: 1));
final hasSession = sessions.any((s) {
final local = s.startTime.toLocal();
return local.isAfter(dayStart) && local.isBefore(dayEnd);
});
if (hasSession) {
streak++;
} else if (i > 0) {
break; // gap in streak
}
}
return streak;
}
Future<Map<String, int>> getWeeklyStats() async {
final sessions = await getSessions();
final now = DateTime.now();
final weekStart = now.subtract(Duration(days: now.weekday % 7));
final weekSessions = sessions.where((s) {
final local = s.startTime.toLocal();
return local.isAfter(DateTime(weekStart.year, weekStart.month, weekStart.day));
}).toList();
final totalSeconds = weekSessions.fold<int>(0, (sum, s) => sum + s.durationSeconds);
return {
'totalSeconds': totalSeconds,
'sessionCount': weekSessions.length,
};
}
/// Returns a list of 7 totals (seconds) for Sun–Sat of the current week
Future<List<int>> getWeeklyDailyTotals() async {
final sessions = await getSessions();
final now = DateTime.now();
// Find the Sunday that started this week
final sunday = now.subtract(Duration(days: now.weekday % 7));
final sundayDate = DateTime(sunday.year, sunday.month, sunday.day);
final totals = List<int>.filled(7, 0);
for (final s in sessions) {
final local = s.startTime.toLocal();
final diff = DateTime(local.year, local.month, local.day).difference(sundayDate).inDays;
if (diff >= 0 && diff < 7) {
totals[diff] += s.durationSeconds;
}
}
return totals;
}
Future<int> getTotalSessions() async {
final sessions = await getSessions();
return sessions.length;
}
Future<int> getTotalSeconds() async {
final sessions = await getSessions();
return sessions.fold(0, (sum, s) => sum + s.durationSeconds);
}
}Bell sounds played using audioplayers. Add the audio files to assets/audio/:
assets/audio/bell_bowl.mp3assets/audio/bell_gong.mp3assets/audio/bell_chime.mp3assets/audio/bell_tibetan.mp3assets/audio/bell_woodblock.mp3
(Use free-license sounds from freesound.org or similar; typical bowl/gong meditation sounds.)
Update pubspec.yaml:
flutter:
assets:
- assets/audio/import 'package:audioplayers/audioplayers.dart';
class MeditationAudioService {
final AudioPlayer _player = AudioPlayer();
Future<void> playBell(String bellType) async {
final file = 'audio/bell_${bellType.toLowerCase()}.mp3';
await _player.play(AssetSource(file));
}
Future<void> dispose() async {
await _player.dispose();
}
}Use flutter_foreground_task to keep the timer counting when the screen is off.
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
class MeditationBackgroundService {
static void initForegroundTask() {
FlutterForegroundTask.init(
androidNotificationOptions: AndroidNotificationOptions(
channelId: 'meditation_timer',
channelName: 'Meditation Timer',
channelDescription: 'Keeps meditation timer running',
channelImportance: NotificationChannelImportance.LOW,
priority: NotificationPriority.LOW,
iconData: const NotificationIconData(
resType: ResourceType.mipmap,
resPrefix: ResourcePrefix.ic,
name: 'launcher',
),
),
iosNotificationOptions: const IOSNotificationOptions(
showNotification: true,
playSound: false,
),
foregroundTaskOptions: const ForegroundTaskOptions(
interval: 1000, // tick every 1 second
isOnceEvent: false,
autoRunOnBoot: false,
allowWakeLock: true,
allowWifiLock: false,
),
);
}
static Future<bool> startService(int totalSeconds) async {
if (await FlutterForegroundTask.isRunningService) {
return FlutterForegroundTask.restartService();
}
return FlutterForegroundTask.startService(
notificationTitle: 'Meditation in progress',
notificationText: 'Timer running...',
callback: startCallback,
taskData: {'totalSeconds': totalSeconds},
);
}
static Future<bool> stopService() async {
return FlutterForegroundTask.stopService();
}
static Future<void> updateNotification(String timeRemaining) async {
await FlutterForegroundTask.updateService(
notificationTitle: 'Meditation in progress',
notificationText: timeRemaining,
);
}
}
// Top-level callback required by flutter_foreground_task
@pragma('vm:entry-point')
void startCallback() {
FlutterForegroundTask.setTaskHandler(MeditationTaskHandler());
}
class MeditationTaskHandler extends TaskHandler {
int _elapsed = 0;
int _total = 0;
@override
Future<void> onStart(DateTime timestamp, TaskStarter starter) async {
_elapsed = 0;
final data = await FlutterForegroundTask.getData<int>(key: 'totalSeconds');
_total = data ?? 0;
}
@override
void onRepeatEvent(DateTime timestamp) {
_elapsed++;
final remaining = _total - _elapsed;
if (remaining <= 0) {
FlutterForegroundTask.updateService(
notificationTitle: 'Meditation complete 🙏',
notificationText: 'Session finished',
);
// Send data back to UI
FlutterForegroundTask.sendDataToMain({'event': 'complete', 'elapsed': _elapsed});
return;
}
final h = remaining ~/ 3600;
final m = (remaining % 3600) ~/ 60;
final s = remaining % 60;
final timeStr = h > 0
? '${h}h ${m.toString().padLeft(2,'0')}m ${s.toString().padLeft(2,'0')}s'
: '${m.toString().padLeft(2,'0')}:${s.toString().padLeft(2,'0')}';
FlutterForegroundTask.updateService(
notificationTitle: 'Meditation in progress',
notificationText: timeStr,
);
FlutterForegroundTask.sendDataToMain({'event': 'tick', 'elapsed': _elapsed});
}
@override
Future<void> onDestroy(DateTime timestamp) async {}
@override
void onReceiveData(Object data) {}
@override
void onNotificationButtonPressed(String id) {}
@override
void onNotificationDismissed() {}
}- Same color palette as upCal:
_bgColor= wheat#F5DEB3/ dark mode = black;_textColor= dark blue#0E0E72/ light;_brownColor= brown. - Timer circle: golden stroke circle with large countdown text inside, matching the existing screenshots.
- Quick-time buttons: 4 pill buttons below the circle showing last-used durations (default: 1h, 2h, 3h, 1h with labels). Tapping one instantly sets the timer duration. Long-pressing opens an inline editor to rename label and change duration.
- "Your Journey" section: streak card + weekly bar chart + total stats.
- Follows existing
_buildSectionCardcard styling.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import '../models/meditation_session.dart';
import '../services/meditation_service.dart';
import '../services/meditation_audio_service.dart';
import '../services/meditation_background_service.dart';
import '../services/settings_service.dart';
class MeditationTimerScreen extends StatefulWidget {
const MeditationTimerScreen({super.key});
@override
State<MeditationTimerScreen> createState() => _MeditationTimerScreenState();
}
class _MeditationTimerScreenState extends State<MeditationTimerScreen>
with WidgetsBindingObserver {
final MeditationService _svc = MeditationService();
final MeditationAudioService _audio = MeditationAudioService();
// Timer state
bool _isRunning = false;
bool _isPrepPhase = false; // countdown before session starts
int _totalSeconds = 4500; // 1h15m default matching screenshot
int _elapsedSeconds = 0;
int _prepSecondsRemaining = 0;
Timer? _timer;
DateTime? _sessionStart;
// Settings
int _intervalSeconds = 0;
int _prepSeconds = 5;
String _bellType = 'chime';
int _lastIntervalBellAt = 0;
// Quick-time presets
List<int> _quickTimes = MeditationService.defaultQuickTimes;
List<String> _quickTimeLabels = List.from(MeditationService.defaultQuickTimeLabels);
// Journey stats
int _streak = 0;
int _weeklySeconds = 0;
int _weeklySessions = 0;
List<int> _weeklyDailyTotals = List.filled(7, 0);
int _totalSessions = 0;
int _totalLifetimeSeconds = 0;
// Dark mode (mirrors app setting)
bool _darkMode = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
MeditationBackgroundService.initForegroundTask();
_loadSettings();
_loadStats();
// Listen for ticks/completion from background service
FlutterForegroundTask.addTaskDataCallback(_onTaskData);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_timer?.cancel();
FlutterForegroundTask.removeTaskDataCallback(_onTaskData);
_audio.dispose();
super.dispose();
}
void _onTaskData(Object data) {
if (data is Map) {
final event = data['event'];
final elapsed = data['elapsed'] as int? ?? 0;
if (!mounted) return;
if (event == 'tick') {
setState(() => _elapsedSeconds = elapsed);
_checkIntervalBell(elapsed);
} else if (event == 'complete') {
_onTimerComplete(elapsed);
}
}
}
Future<void> _loadSettings() async {
final results = await Future.wait([
SettingsService.getDarkMode(),
_svc.getBellType(),
_svc.getIntervalSeconds(),
_svc.getPrepSeconds(),
_svc.getQuickTimes(),
_svc.getQuickTimeLabels(),
]);
if (!mounted) return;
setState(() {
_darkMode = results[0] as bool;
_bellType = results[1] as String;
_intervalSeconds = results[2] as int;
_prepSeconds = results[3] as int;
_quickTimes = results[4] as List<int>;
_quickTimeLabels = results[5] as List<String>;
});
}
Future<void> _loadStats() async {
final results = await Future.wait([
_svc.getCurrentStreak(),
_svc.getWeeklyStats(),
_svc.getWeeklyDailyTotals(),
_svc.getTotalSessions(),
_svc.getTotalSeconds(),
]);
if (!mounted) return;
final weekly = results[1] as Map<String, int>;
setState(() {
_streak = results[0] as int;
_weeklySeconds = weekly['totalSeconds'] ?? 0;
_weeklySessions = weekly['sessionCount'] ?? 0;
_weeklyDailyTotals = results[2] as List<int>;
_totalSessions = results[3] as int;
_totalLifetimeSeconds = results[4] as int;
});
}
// ── Timer logic ────────────────────────────────────────────────────
void _startTimer() async {
if (_isRunning) return;
// Request foreground task permission on Android 13+
if (!await FlutterForegroundTask.isIgnoringBatteryOptimizations) {
await FlutterForegroundTask.requestIgnoreBatteryOptimization();
}
setState(() {
_isPrepPhase = _prepSeconds > 0;
_prepSecondsRemaining = _prepSeconds;
_elapsedSeconds = 0;
_lastIntervalBellAt = 0;
});
if (_isPrepPhase) {
// Prep countdown
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
setState(() => _prepSecondsRemaining--);
if (_prepSecondsRemaining <= 0) {
_timer?.cancel();
setState(() => _isPrepPhase = false);
_beginActualSession();
}
});
} else {
_beginActualSession();
}
}
void _beginActualSession() async {
_sessionStart = DateTime.now();
setState(() => _isRunning = true);
// Play opening bell
await _audio.playBell(_bellType);
// Start foreground service for background timer
await MeditationBackgroundService.startService(_totalSeconds);
// Also run a local timer as UI fallback
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
// Local timer is secondary — foreground task drives elapsed via _onTaskData.
// This local one ensures UI ticks even if data callback is delayed.
});
}
void _pauseTimer() {
_timer?.cancel();
setState(() => _isRunning = false);
MeditationBackgroundService.stopService();
}
void _resetTimer() {
_timer?.cancel();
MeditationBackgroundService.stopService();
setState(() {
_isRunning = false;
_isPrepPhase = false;
_elapsedSeconds = 0;
_prepSecondsRemaining = 0;
_sessionStart = null;
});
}
void _checkIntervalBell(int elapsed) {
if (_intervalSeconds <= 0) return;
if (elapsed > 0 && elapsed % _intervalSeconds == 0 && elapsed != _lastIntervalBellAt) {
_lastIntervalBellAt = elapsed;
_audio.playBell(_bellType);
}
}
void _onTimerComplete(int elapsed) async {
_timer?.cancel();
await MeditationBackgroundService.stopService();
// Play completion bell (3 times)
for (int i = 0; i < 3; i++) {
await _audio.playBell(_bellType);
await Future.delayed(const Duration(milliseconds: 800));
}
final session = MeditationSession(
startTime: _sessionStart ?? DateTime.now(),
durationSeconds: elapsed,
targetSeconds: _totalSeconds,
bellType: _bellType,
);
await _svc.saveSession(session);
setState(() {
_isRunning = false;
_elapsedSeconds = 0;
_sessionStart = null;
});
await _loadStats();
if (mounted) {
_showCompletionDialog(elapsed);
}
}
void _showCompletionDialog(int seconds) {
final mins = seconds ~/ 60;
showDialog(
context: context,
builder: (_) => AlertDialog(
backgroundColor: _bgColor,
title: Text('Session Complete 🙏', style: TextStyle(color: _textColor)),
content: Text(
'You meditated for $mins minutes.\nMay your mind be at peace.',
style: TextStyle(color: _textColor),
),
actions: [
ElevatedButton(
onPressed: () => Navigator.pop(context),
style: ElevatedButton.styleFrom(
backgroundColor: _brownColor,
foregroundColor: Colors.white,
),
child: const Text('Close'),
),
],
),
);
}
// ── Quick-time buttons ─────────────────────────────────────────────
void _applyQuickTime(int index) {
if (_isRunning) return;
setState(() => _totalSeconds = _quickTimes[index]);
}
void _editQuickTime(int index) async {
int hours = _quickTimes[index] ~/ 3600;
int minutes = (_quickTimes[index] % 3600) ~/ 60;
String label = _quickTimeLabels[index];
final hCtrl = TextEditingController(text: hours.toString());
final mCtrl = TextEditingController(text: minutes.toString());
final lCtrl = TextEditingController(text: label);
await showDialog(
context: context,
builder: (_) => AlertDialog(
backgroundColor: _bgColor,
title: Text('Edit Quick Time', style: TextStyle(color: _textColor)),
content: Column(mainAxisSize: MainAxisSize.min, children: [
TextField(
controller: lCtrl,
decoration: InputDecoration(labelText: 'Label', labelStyle: TextStyle(color: _brownColor)),
style: TextStyle(color: _textColor),
),
const SizedBox(height: 8),
Row(children: [
Expanded(child: TextField(
controller: hCtrl,
keyboardType: TextInputType.number,
decoration: InputDecoration(labelText: 'Hours', labelStyle: TextStyle(color: _brownColor)),
style: TextStyle(color: _textColor),
)),
const SizedBox(width: 12),
Expanded(child: TextField(
controller: mCtrl,
keyboardType: TextInputType.number,
decoration: InputDecoration(labelText: 'Minutes', labelStyle: TextStyle(color: _brownColor)),
style: TextStyle(color: _textColor),
)),
]),
]),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel', style: TextStyle(color: _brownColor)),
),
ElevatedButton(
onPressed: () async {
final h = int.tryParse(hCtrl.text) ?? 0;
final m = int.tryParse(mCtrl.text) ?? 0;
final secs = h * 3600 + m * 60;
if (secs > 0) {
await _svc.setQuickTime(index, secs);
await _svc.setQuickTimeLabel(index, lCtrl.text.trim().isNotEmpty ? lCtrl.text.trim() : label);
await _loadSettings();
setState(() => _totalSeconds = secs);
}
if (mounted) Navigator.pop(context);
},
style: ElevatedButton.styleFrom(backgroundColor: _brownColor, foregroundColor: Colors.white),
child: const Text('Save'),
),
],
),
);
}
// ── Settings dialog ────────────────────────────────────────────────
void _openSettings() async {
int hours = _totalSeconds ~/ 3600;
int minutes = (_totalSeconds % 3600) ~/ 60;
int intMins = _intervalSeconds ~/ 60;
int intSecs = _intervalSeconds % 60;
int prepSecs = _prepSeconds;
String bellType = _bellType;
final hCtrl = TextEditingController(text: hours.toString());
final mCtrl = TextEditingController(text: minutes.toString());
final iMCtrl = TextEditingController(text: intMins.toString());
final iSCtrl = TextEditingController(text: intSecs.toString());
final bells = ['bowl', 'gong', 'chime', 'tibetan', 'woodblock'];
final prepOptions = [0, 3, 5, 10, 15, 30];
await showDialog(
context: context,
builder: (_) => StatefulBuilder(builder: (ctx, setS) => AlertDialog(
backgroundColor: _bgColor,
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
title: Text('Session Settings', style: TextStyle(color: _textColor, fontWeight: FontWeight.bold)),
content: SingleChildScrollView(child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Duration
Text('DURATION (HOURS & MINUTES)', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: _brownColor)),
const SizedBox(height: 8),
Row(children: [
_spinnerField(hCtrl, 'Hours', 0, 12, (v) { hCtrl.text = v.toString(); }),
const SizedBox(width: 12),
_spinnerField(mCtrl, 'Minutes', 0, 59, (v) { mCtrl.text = v.toString(); }),
]),
const SizedBox(height: 16),
// Interval bell
Text('INTERVAL BELL (MINUTES & SECONDS)', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: _brownColor)),
const SizedBox(height: 8),
Row(children: [
_spinnerField(iMCtrl, 'Minutes', 0, 120, (v) { iMCtrl.text = v.toString(); }),
const SizedBox(width: 12),
_spinnerField(iSCtrl, 'Seconds', 0, 59, (v) { iSCtrl.text = v.toString(); }),
]),
const SizedBox(height: 16),
// Prep check
Text('PREPARATION CHECK (SEC)', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: _brownColor)),
const SizedBox(height: 8),
DropdownButtonFormField<int>(
value: prepSecs,
items: prepOptions.map((s) => DropdownMenuItem(value: s, child: Text('$s seconds'))).toList(),
onChanged: (v) => setS(() => prepSecs = v ?? 5),
decoration: InputDecoration(
border: OutlineInputBorder(borderSide: BorderSide(color: _brownColor.withOpacity(0.4))),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
),
const SizedBox(height: 16),
// Bell type
Text('BELL TYPE', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: _brownColor)),
const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: bells.map((b) => Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(b[0].toUpperCase() + b.substring(1)),
selected: bellType == b,
selectedColor: _brownColor.withOpacity(0.2),
side: BorderSide(color: bellType == b ? _brownColor : Colors.grey.withOpacity(0.3)),
labelStyle: TextStyle(color: bellType == b ? _brownColor : _textColor),
onSelected: (_) {
setS(() => bellType = b);
_audio.playBell(b); // preview
},
),
)).toList(),
),
),
],
)),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text('Cancel', style: TextStyle(color: _brownColor))),
ElevatedButton(
onPressed: () async {
final h = int.tryParse(hCtrl.text) ?? 0;
final m = int.tryParse(mCtrl.text) ?? 0;
final iM = int.tryParse(iMCtrl.text) ?? 0;
final iS = int.tryParse(iSCtrl.text) ?? 0;
final secs = h * 3600 + m * 60;
if (secs > 0) {
setState(() {
_totalSeconds = secs;
_intervalSeconds = iM * 60 + iS;
_prepSeconds = prepSecs;
_bellType = bellType;
});
await _svc.setBellType(bellType);
await _svc.setIntervalSeconds(_intervalSeconds);
await _svc.setPrepSeconds(prepSecs);
}
if (mounted) Navigator.pop(ctx);
},
style: ElevatedButton.styleFrom(backgroundColor: _brownColor, foregroundColor: Colors.white),
child: const Text('SAVE SETTINGS'),
),
],
)),
);
}
Widget _spinnerField(TextEditingController ctrl, String label, int min, int max, void Function(int) onChanged) {
return Expanded(
child: Container(
decoration: BoxDecoration(
border: Border.all(color: _brownColor.withOpacity(0.5)),
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(children: [
Expanded(child: TextField(
controller: ctrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(border: InputBorder.none, isDense: true),
style: TextStyle(fontSize: 20, color: _textColor),
)),
Column(children: [
GestureDetector(
onTap: () {
final v = (int.tryParse(ctrl.text) ?? min);
if (v < max) onChanged(v + 1);
},
child: Icon(Icons.keyboard_arrow_up, size: 16, color: _brownColor),
),
GestureDetector(
onTap: () {
final v = (int.tryParse(ctrl.text) ?? min);
if (v > min) onChanged(v - 1);
},
child: Icon(Icons.keyboard_arrow_down, size: 16, color: _brownColor),
),
]),
]),
),
);
}
// ── Colors (matching home_screen.dart exactly) ─────────────────────
Color get _bgColor => _darkMode ? Colors.black : const Color(0xFFF5DEB3);
Color get _textColor => _darkMode ? const Color(0xFFEDEDED) : const Color(0xFF0E0E72);
Color get _greyColor => _darkMode ? const Color(0xFFEDEDED) : Colors.grey[600]!;
Color get _brownColor => _darkMode ? const Color(0xFF9A5A2C) : Colors.brown;
// ── Formatting helpers ─────────────────────────────────────────────
String _formatTime(int seconds) {
final remaining = (_totalSeconds - _elapsedSeconds).clamp(0, _totalSeconds);
final h = remaining ~/ 3600;
final m = (remaining % 3600) ~/ 60;
final s = remaining % 60;
if (h > 0) {
return '$h:${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
}
return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
}
String _formatDuration(int seconds) {
if (seconds == 0) return '0m';
final h = seconds ~/ 3600;
final m = (seconds % 3600) ~/ 60;
if (h > 0 && m > 0) return '${h}h ${m}m';
if (h > 0) return '${h}h';
return '${m}m';
}
String _formatQuickTime(int seconds) {
final h = seconds ~/ 3600;
final m = (seconds % 3600) ~/ 60;
if (h > 0 && m > 0) return '${h}h ${m}m';
if (h > 0) return '${h}h';
return '${m}m';
}
double get _progress => _totalSeconds > 0 ? (_elapsedSeconds / _totalSeconds).clamp(0.0, 1.0) : 0.0;
// ── Build ──────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: _bgColor,
appBar: AppBar(
backgroundColor: _bgColor,
foregroundColor: _textColor,
surfaceTintColor: Colors.transparent,
elevation: 0,
title: Text('Meditation', style: TextStyle(color: _textColor)),
),
body: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
children: [
const SizedBox(height: 16),
_buildTimerCircle(),
const SizedBox(height: 24),
_buildQuickTimeButtons(),
const SizedBox(height: 8),
_buildTimerControls(),
const SizedBox(height: 24),
_buildJourneySection(),
const SizedBox(height: 32),
],
),
),
);
}
Widget _buildTimerCircle() {
return Center(
child: SizedBox(
width: 240,
height: 240,
child: Stack(alignment: Alignment.center, children: [
// Background track
SizedBox(
width: 240,
height: 240,
child: CircularProgressIndicator(
value: 1.0,
strokeWidth: 6,
color: _brownColor.withOpacity(0.15),
),
),
// Progress arc
SizedBox(
width: 240,
height: 240,
child: CircularProgressIndicator(
value: _progress,
strokeWidth: 6,
color: _brownColor,
strokeCap: StrokeCap.round,
),
),
// Inner content
Column(mainAxisAlignment: MainAxisAlignment.center, children: [
if (_isPrepPhase) ...[
Text('GET READY', style: TextStyle(fontSize: 12, letterSpacing: 1.5, color: _greyColor)),
const SizedBox(height: 4),
Text(
'$_prepSecondsRemaining',
style: TextStyle(fontSize: 56, fontWeight: FontWeight.bold, color: _textColor),
),
] else ...[
Text(
_isRunning ? 'REMAINING' : 'DURATION',
style: TextStyle(fontSize: 12, letterSpacing: 1.5, color: _greyColor),
),
const SizedBox(height: 4),
Text(
_formatTime(_totalSeconds - _elapsedSeconds),
style: TextStyle(
fontSize: _totalSeconds >= 3600 ? 40 : 48,
fontWeight: FontWeight.bold,
color: _textColor,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
]),
]),
),
);
}
Widget _buildTimerControls() {
return Column(children: [
// Configure link
GestureDetector(
onTap: _isRunning ? null : _openSettings,
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.tune, size: 14, color: _greyColor),
const SizedBox(width: 4),
Text('CONFIGURE', style: TextStyle(fontSize: 12, letterSpacing: 1.2, color: _greyColor)),
]),
),
const SizedBox(height: 12),
// Control row: reset | start/pause | volume
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
// Reset
IconButton(
icon: Icon(Icons.replay, color: _isRunning ? _greyColor : _textColor),
onPressed: _resetTimer,
tooltip: 'Reset',
),
const SizedBox(width: 8),
// Start / Pause
ElevatedButton.icon(
onPressed: _isRunning ? _pauseTimer : _startTimer,
icon: Icon(_isRunning ? Icons.pause : Icons.play_arrow, size: 18),
label: Text(
_isRunning ? 'PAUSE' : (_elapsedSeconds > 0 ? 'RESUME' : 'START MEDITATION'),
style: const TextStyle(fontWeight: FontWeight.bold, letterSpacing: 0.8),
),
style: ElevatedButton.styleFrom(
backgroundColor: _brownColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(30)),
),
),
const SizedBox(width: 8),
// Volume preview bell
IconButton(
icon: Icon(Icons.volume_up, color: _textColor),
onPressed: () => _audio.playBell(_bellType),
tooltip: 'Preview bell',
),
]),
]);
}
Widget _buildQuickTimeButtons() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8),
child: Text(
'QUICK SET',
style: TextStyle(fontSize: 10, letterSpacing: 1.5, color: _greyColor),
),
),
Row(
children: List.generate(4, (i) {
final isSelected = _totalSeconds == _quickTimes[i];
return Expanded(
child: Padding(
padding: EdgeInsets.only(right: i < 3 ? 8 : 0),
child: GestureDetector(
onLongPress: () => _editQuickTime(i),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: isSelected ? _brownColor.withOpacity(0.15) : _bgColor,
border: Border.all(
color: isSelected ? _brownColor : _brownColor.withOpacity(0.35),
width: isSelected ? 1.5 : 1.0,
),
borderRadius: BorderRadius.circular(12),
),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _applyQuickTime(i),
onLongPress: () => _editQuickTime(i),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Text(
_formatQuickTime(_quickTimes[i]),
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: isSelected ? _brownColor : _textColor,
),
),
const SizedBox(height: 2),
Text(
_quickTimeLabels[i],
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 9, color: _greyColor),
),
]),
),
),
),
),
),
);
}),
),
Padding(
padding: const EdgeInsets.only(top: 6, left: 4),
child: Text(
'Long-press any button to customize',
style: TextStyle(fontSize: 9, color: _greyColor.withOpacity(0.7)),
),
),
],
);
}
// ── Journey section ────────────────────────────────────────────────
Widget _buildJourneySection() {
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: _bgColor,
border: Border.all(
color: _darkMode ? const Color(0xFFD0D0D0) : const Color(0xFF0E0E72),
width: 2,
),
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(Icons.show_chart, size: 16, color: _textColor),
const SizedBox(width: 6),
Text('Your Journey', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: _textColor)),
]),
const SizedBox(height: 12),
// Streak card
_buildStreakCard(),
const SizedBox(height: 12),
// Weekly stats row
Row(children: [
Expanded(child: _buildStatTile('WEEKLY TIME', _formatDuration(_weeklySeconds))),
Expanded(child: _buildStatTile('SESSIONS', _weeklySessions.toString())),
Expanded(child: _buildStatTile('ALL TIME', _formatDuration(_totalLifetimeSeconds))),
]),
const SizedBox(height: 12),
// Weekly bar chart
_buildWeeklyChart(),
],
),
);
}
Widget _buildStreakCard() {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
decoration: BoxDecoration(
color: _brownColor.withOpacity(0.08),
borderRadius: BorderRadius.circular(8),
),
child: Column(children: [
Icon(Icons.workspace_premium, color: _brownColor, size: 28),
const SizedBox(height: 4),
Text('CURRENT STREAK', style: TextStyle(fontSize: 11, letterSpacing: 1.2, color: _greyColor)),
const SizedBox(height: 2),
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
Text('$_streak', style: TextStyle(fontSize: 36, fontWeight: FontWeight.bold, color: _brownColor)),
const SizedBox(width: 6),
Text('Days', style: TextStyle(fontSize: 20, color: _brownColor)),
]),
]),
);
}
Widget _buildStatTile(String label, String value) {
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: TextStyle(fontSize: 10, letterSpacing: 1.0, color: _greyColor)),
const SizedBox(height: 2),
Text(value, style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: _brownColor)),
]);
}
Widget _buildWeeklyChart() {
final days = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
final maxVal = _weeklyDailyTotals.reduce((a, b) => a > b ? a : b);
final today = DateTime.now().weekday % 7; // 0=Sun, 6=Sat
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Meditation History', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: _textColor)),
const SizedBox(height: 12),
SizedBox(
height: 80,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: List.generate(7, (i) {
final val = _weeklyDailyTotals[i];
final fraction = maxVal > 0 ? val / maxVal : 0.0;
final isToday = i == today;
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Expanded(
child: Align(
alignment: Alignment.bottomCenter,
child: AnimatedContainer(
duration: const Duration(milliseconds: 400),
width: double.infinity,
height: fraction > 0 ? (fraction * 60).clamp(4.0, 60.0) : 2,
decoration: BoxDecoration(
color: isToday ? _brownColor : _brownColor.withOpacity(0.4),
borderRadius: BorderRadius.circular(4),
),
),
),
),
],
),
),
);
}),
),
),
const SizedBox(height: 4),
Divider(height: 1, color: _greyColor.withOpacity(0.3)),
const SizedBox(height: 4),
Row(
children: List.generate(7, (i) {
return Expanded(
child: Text(
days[i],
textAlign: TextAlign.center,
style: TextStyle(fontSize: 11, color: _greyColor),
),
);
}),
),
const SizedBox(height: 8),
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Text('Daily Average', style: TextStyle(fontSize: 11, color: _greyColor)),
Text(
'${(_weeklyDailyTotals.reduce((a, b) => a + b) / 7 / 60).toStringAsFixed(1)} mins',
style: TextStyle(fontSize: 11, color: _brownColor),
),
]),
],
);
}
}Add these to all your assets/translations/*.json files (adjust translations for non-English):
{
"meditation_timer": "Meditation Timer",
"meditation_start": "START MEDITATION",
"meditation_pause": "PAUSE",
"meditation_resume": "RESUME",
"meditation_reset": "Reset",
"meditation_configure": "CONFIGURE",
"meditation_quick_set": "QUICK SET",
"meditation_customize_hint": "Long-press any button to customize",
"meditation_journey": "Your Journey",
"meditation_streak": "CURRENT STREAK",
"meditation_weekly_time": "WEEKLY TIME",
"meditation_sessions": "SESSIONS",
"meditation_all_time": "ALL TIME",
"meditation_history": "Meditation History",
"meditation_daily_avg": "Daily Average",
"meditation_complete_title": "Session Complete 🙏",
"meditation_complete_body": "You meditated for {mins} minutes.\nMay your mind be at peace.",
"meditation_get_ready": "GET READY",
"meditation_remaining": "REMAINING",
"meditation_duration": "DURATION",
"meditation_session_settings": "Session Settings",
"meditation_duration_label": "DURATION (HOURS & MINUTES)",
"meditation_interval_label": "INTERVAL BELL (MINUTES & SECONDS)",
"meditation_prep_label": "PREPARATION CHECK (SEC)",
"meditation_bell_type": "BELL TYPE",
"meditation_save_settings": "SAVE SETTINGS",
"meditation_edit_quick": "Edit Quick Time",
"meditation_label": "Label",
"meditation_days": "Days"
}- Timer counts down correctly when screen is on
- Timer continues when screen is locked (foreground notification visible on Android)
- Timer completes and bell rings 3× on finish
- Interval bell fires at correct interval during session
- Prep countdown shows before session starts
- Quick-time buttons apply instantly when tapped
- Long-pressing a quick-time button opens edit dialog
- Edited quick-times persist after app restart
- Sessions save to SharedPreferences on completion
- Streak increments correctly across days
- Bar chart shows correct daily totals for current week
- Bell type preview plays when tapped in settings
- Dark mode colors match the rest of the app
- Leading meditation icon in home screen AppBar opens timer
You need 5 MP3 bell files. Recommended free sources:
- freesound.org (CC0 license) — search "tibetan bowl", "temple bell", "wind chime"
- pixabay.com/music — search "meditation bell"
Place them at:
assets/audio/bell_bowl.mp3
assets/audio/bell_gong.mp3
assets/audio/bell_chime.mp3
assets/audio/bell_tibetan.mp3
assets/audio/bell_woodblock.mp3
| File | Action |
|---|---|
lib/screens/home_screen.dart |
Add leading: icon to AppBar |
lib/screens/meditation_timer_screen.dart |
Create new |
lib/models/meditation_session.dart |
Create new |
lib/services/meditation_service.dart |
Create new |
lib/services/meditation_audio_service.dart |
Create new |
lib/services/meditation_background_service.dart |
Create new |
pubspec.yaml |
Add dependencies + audio assets |
ios/Runner/Info.plist |
Add audio background mode |
android/app/src/main/AndroidManifest.xml |
Add foreground service + wake lock permissions |
assets/audio/*.mp3 |
Add 5 bell sound files |
assets/translations/*.json |
Add meditation localization strings |