import 'package:flutter/material.dart'; import 'dart:async'; import 'package:flutter/services.dart'; import 'dart:io'; import 'dart:convert'; import 'dart:developer' as developer; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Just a timer', darkTheme: ThemeData( brightness: Brightness.dark, useMaterial3: true, scaffoldBackgroundColor: const Color(0xFF111827), colorSchemeSeed: const Color(0xFF111827), highlightColor: const Color(0xff1f2937), textTheme: TextTheme( bodyLarge: TextStyle(fontSize: 22), bodyMedium: TextStyle(fontSize: 20), bodySmall: TextStyle(fontSize: 17), ), ), home: const MyHomePage(title: 'Just a timer'), ); } } class MyHomePage extends StatefulWidget { const MyHomePage({super.key, required this.title}); final String title; @override State createState() => _MyHomePageState(); } class TimerEvent { final String type; // "start", "pause", "lap", "stop" final DateTime timestamp; TimerEvent(this.type, this.timestamp); Map toJson() => { 'type': type, 'timestamp': timestamp.toIso8601String(), }; factory TimerEvent.fromJson(Map json) { return TimerEvent( json['type'] as String, DateTime.parse(json['timestamp'] as String), ); } } class _MyHomePageState extends State { Timer? _timer; DateTime? _startTime; DateTime? _pauseTime; Duration _elapsedTime = Duration.zero; bool _isRunning = false; List _laps = []; final FocusNode _focusNode = FocusNode(); List _events = []; @override void initState() { super.initState(); _focusNode.requestFocus(); _loadTimerData(); } // Simple file operations using app's temporary directory Future get _timerFile async { final directory = Directory.systemTemp.path; return File('$directory/timer_data.json'); } Future _saveTimerData() async { try { final file = await _timerFile; final data = { 'events': _events.map((e) => e.toJson()).toList(), 'laps': _laps, }; await file.writeAsString(jsonEncode(data)); } catch (e) { // Handle error silently using proper logging developer.log('Failed to save timer data: $e', name: 'simple_timer'); } } Future _loadTimerData() async { try { final file = await _timerFile; if (await file.exists()) { final jsonData = await file.readAsString(); final data = jsonDecode(jsonData) as Map; // Load events _events = (data['events'] as List) .map((e) => TimerEvent.fromJson(e as Map)) .toList(); // Load laps _laps = (data['laps'] as List).cast(); // Reconstruct timer state from events _reconstructTimerState(); } } catch (e) { // Handle error silently using proper logging developer.log('Failed to load timer data: $e', name: 'simple_timer'); } } void _reconstructTimerState() { if (_events.isEmpty) return; // Find the most recent event sequence DateTime? lastStartTime; DateTime? lastPauseTime; Duration totalPausedTime = Duration.zero; Duration accumulatedRunningTime = Duration.zero; bool wasRunning = false; // Process events chronologically to determine the current state for (int i = 0; i < _events.length; i++) { final event = _events[i]; switch (event.type) { case 'start': if (lastStartTime == null) { // New start lastStartTime = event.timestamp; wasRunning = true; } else if (lastPauseTime != null) { // Resume after pause - add the pause duration to totalPausedTime totalPausedTime += event.timestamp.difference(lastPauseTime); lastPauseTime = null; wasRunning = true; } break; case 'pause': if (lastStartTime != null && lastPauseTime == null) { // Pause an active timer lastPauseTime = event.timestamp; accumulatedRunningTime += lastPauseTime.difference(lastStartTime); wasRunning = false; } break; case 'stop': // Reset everything lastStartTime = null; lastPauseTime = null; totalPausedTime = Duration.zero; accumulatedRunningTime = Duration.zero; wasRunning = false; break; } } // Determine timer state based on most recent events if (lastStartTime != null) { if (wasRunning) { // Timer was running when app was closed // Calculate elapsed time: current time - start time - total paused time final now = DateTime.now(); _elapsedTime = now.difference(lastStartTime) - totalPausedTime; _startTime = lastStartTime; _pauseTime = null; _isRunning = true; // Start the timer without resetting anything _resumeTimerFromSavedState(); } else { // Timer was paused _startTime = lastStartTime; _pauseTime = lastPauseTime; _elapsedTime = accumulatedRunningTime; _isRunning = false; } } } // Resume the timer from a saved state without resetting anything void _resumeTimerFromSavedState() { // Safety check if (_startTime == null) return; _timer = Timer.periodic(const Duration(milliseconds: 16), (timer) { setState(() { // When restoring, we need the total elapsed time since the original start // This accounts for any pauses that happened before final now = DateTime.now(); _elapsedTime = now.difference(_startTime!); }); }); setState(() { _isRunning = true; }); } void _addEvent(String type) { final event = TimerEvent(type, DateTime.now()); _events.add(event); _saveTimerData(); } void _startTimer() { final now = DateTime.now(); if (_pauseTime != null) { // Resuming from pause final pausedDuration = now.difference(_pauseTime!); _startTime = _startTime?.add(pausedDuration); _pauseTime = null; _addEvent('start'); } else { // Starting fresh _startTime = now; _elapsedTime = Duration.zero; _laps = []; _events = []; // Clear previous events _addEvent('start'); } _timer = Timer.periodic(const Duration(milliseconds: 16), (timer) { setState(() { _elapsedTime = DateTime.now().difference(_startTime!); }); }); setState(() { _isRunning = true; }); } void _pauseTimer() { _timer?.cancel(); _pauseTime = DateTime.now(); _addEvent('pause'); setState(() { _isRunning = false; }); } void _stopTimer() { _timer?.cancel(); _addEvent('stop'); setState(() { _isRunning = false; _startTime = null; _pauseTime = null; _elapsedTime = Duration.zero; }); _saveTimerData(); } void _recordLap() { if (_isRunning) { final now = DateTime.now(); _addEvent('lap'); setState(() { // Store both timestamp and formatted duration for this lap _laps.add('${now.toIso8601String()} - ${_formatDurationForLap(_elapsedTime)}'); }); _saveTimerData(); } } String _formatDuration(Duration duration) { // Format with fixed width on a single line, hiding zero hours/minutes final hours = duration.inHours; final minutes = duration.inMinutes.remainder(60); final seconds = duration.inSeconds.remainder(60); final milliseconds = duration.inMilliseconds.remainder(1000); // Use padLeft to ensure consistent width with leading zeros final secondsStr = seconds.toString().padLeft(2, '0'); final msStr = milliseconds.toString().padLeft(3, '0'); // Build output - only show non-zero hours and minutes StringBuffer result = StringBuffer(); if (hours > 0) { result.write('${hours.toString().padLeft(2, '0')}H '); } if (hours > 0 || minutes > 0) { result.write('${minutes.toString().padLeft(2, '0')}M '); } // Always show seconds and milliseconds result.write('${secondsStr}S ${msStr}MS'); return result.toString(); } // For lap recording - use the same format String _formatDurationForLap(Duration duration) { return _formatDuration(duration); } // Update keyboard handler method to use modern KeyEvent API void _handleKeyEvent(KeyEvent event) { // Only process key down events to avoid duplicate triggers if (event is KeyDownEvent) { // Handle Space key if (event.logicalKey == LogicalKeyboardKey.space) { if (_isRunning) { _recordLap(); } else { _startTimer(); } } // Handle Enter key else if (event.logicalKey == LogicalKeyboardKey.enter) { if (_isRunning) { _pauseTimer(); } else { _stopTimer(); } } } } @override void dispose() { _timer?.cancel(); _focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { // Use Focus directly to capture key events return Focus( focusNode: _focusNode, autofocus: true, onKeyEvent: (FocusNode node, KeyEvent event) { _handleKeyEvent(event); // Always return KeyEventResult.handled to indicate we've processed the event return KeyEventResult.handled; }, child: Scaffold( appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.inversePrimary, title: Text(widget.title), ), body: GestureDetector( // This ensures tapping anywhere gives focus back to our listener onTap: () => _focusNode.requestFocus(), behavior: HitTestBehavior.translucent, child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const SizedBox(height: 10), // Use a fixed height container to prevent expanding and force horizontal scrolling if needed SizedBox( height: 50, child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Text( _formatDuration(_elapsedTime), style: Theme.of(context).textTheme.headlineMedium?.copyWith( fontFamily: 'monospace', // Use monospaced font for fixed-width characters ), softWrap: false, // Prevent text wrapping overflow: TextOverflow.visible, // Allow text to extend beyond bounds ), ), ), const SizedBox(height: 30), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ ElevatedButton( onPressed: _isRunning ? _pauseTimer : _startTimer, child: Text(_isRunning ? 'Pause' : 'Start'), ), const SizedBox(width: 20), ElevatedButton( onPressed: _stopTimer, child: const Text('Stop'), ), const SizedBox(width: 20), ElevatedButton( onPressed: _recordLap, child: const Text('Lap'), ), ], ), const SizedBox(height: 20), if (_laps.isNotEmpty) ...[ Text( 'Laps:', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 10), Expanded( child: ListView.builder( itemCount: _laps.length, itemBuilder: (context, index) { return ListTile( title: Text('Lap ${index + 1}: ${_laps[index]}'), ); }, ), ), ], ], ), ), ), ), ); } }