Skip to content

Instantly share code, notes, and snippets.

@vpnry
Created May 30, 2026 16:33
Show Gist options
  • Select an option

  • Save vpnry/c34412136caf5d61947678278fc66de5 to your computer and use it in GitHub Desktop.

Select an option

Save vpnry/c34412136caf5d61947678278fc66de5 to your computer and use it in GitHub Desktop.
Claude design meditation timer
image

upCal Meditation Timer — Full Implementation Prompt

Overview

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).


1. Entry Point: Home Screen Icon

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,
    ),
  ],
),

2. New Files to Create

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)

3. pubspec.yaml Dependencies to Add

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" />

4. Data Model: meditation_session.dart

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',
  );
}

5. Service: meditation_service.dart

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);
  }
}

6. Service: meditation_audio_service.dart

Bell sounds played using audioplayers. Add the audio files to assets/audio/:

  • 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

(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();
  }
}

7. Background Timer: meditation_background_service.dart

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() {}
}

8. Main Screen: meditation_timer_screen.dart

Design Principles

  • 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 _buildSectionCard card styling.

Complete Implementation

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),
          ),
        ]),
      ],
    );
  }
}

9. Localization Strings to Add

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"
}

10. Testing Checklist

  • 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

11. Audio Assets

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

Summary of Files Changed / Created

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment