Files
flutter-simple-timer/lib/main.dart
2025-03-15 00:07:30 +01:00

426 lines
12 KiB
Dart

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<MyHomePage> createState() => _MyHomePageState();
}
class TimerEvent {
final String type; // "start", "pause", "lap", "stop"
final DateTime timestamp;
TimerEvent(this.type, this.timestamp);
Map<String, dynamic> toJson() => {
'type': type,
'timestamp': timestamp.toIso8601String(),
};
factory TimerEvent.fromJson(Map<String, dynamic> json) {
return TimerEvent(
json['type'] as String,
DateTime.parse(json['timestamp'] as String),
);
}
}
class _MyHomePageState extends State<MyHomePage> {
Timer? _timer;
DateTime? _startTime;
DateTime? _pauseTime;
Duration _elapsedTime = Duration.zero;
bool _isRunning = false;
List<String> _laps = [];
final FocusNode _focusNode = FocusNode();
List<TimerEvent> _events = [];
@override
void initState() {
super.initState();
_focusNode.requestFocus();
_loadTimerData();
}
// Simple file operations using app's temporary directory
Future<File> get _timerFile async {
final directory = Directory.systemTemp.path;
return File('$directory/timer_data.json');
}
Future<void> _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<void> _loadTimerData() async {
try {
final file = await _timerFile;
if (await file.exists()) {
final jsonData = await file.readAsString();
final data = jsonDecode(jsonData) as Map<String, dynamic>;
// Load events
_events = (data['events'] as List)
.map((e) => TimerEvent.fromJson(e as Map<String, dynamic>))
.toList();
// Load laps
_laps = (data['laps'] as List).cast<String>();
// 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]}'),
);
},
),
),
],
],
),
),
),
),
);
}
}