235 lines
6.5 KiB
Dart
235 lines
6.5 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'dart:async';
|
|
import 'package:flutter/services.dart';
|
|
|
|
void main() {
|
|
runApp(const MyApp());
|
|
}
|
|
|
|
class MyApp extends StatelessWidget {
|
|
const MyApp({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return MaterialApp(
|
|
title: 'Simple Timer',
|
|
theme: ThemeData(
|
|
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
|
),
|
|
home: const MyHomePage(title: 'Simple Timer'),
|
|
);
|
|
}
|
|
}
|
|
|
|
class MyHomePage extends StatefulWidget {
|
|
const MyHomePage({super.key, required this.title});
|
|
final String title;
|
|
@override
|
|
State<MyHomePage> createState() => _MyHomePageState();
|
|
}
|
|
|
|
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();
|
|
|
|
void _startTimer() {
|
|
final now = DateTime.now();
|
|
if (_pauseTime != null) {
|
|
// Resuming from pause
|
|
final pausedDuration = now.difference(_pauseTime!);
|
|
_startTime = _startTime?.add(pausedDuration);
|
|
_pauseTime = null;
|
|
} else {
|
|
// Starting fresh
|
|
_startTime = now;
|
|
_elapsedTime = Duration.zero;
|
|
_laps = [];
|
|
}
|
|
|
|
_timer = Timer.periodic(const Duration(milliseconds: 16), (timer) {
|
|
setState(() {
|
|
_elapsedTime = DateTime.now().difference(_startTime!);
|
|
});
|
|
});
|
|
|
|
setState(() {
|
|
_isRunning = true;
|
|
});
|
|
}
|
|
|
|
void _pauseTimer() {
|
|
_timer?.cancel();
|
|
_pauseTime = DateTime.now();
|
|
setState(() {
|
|
_isRunning = false;
|
|
});
|
|
}
|
|
|
|
void _stopTimer() {
|
|
_timer?.cancel();
|
|
setState(() {
|
|
_isRunning = false;
|
|
_startTime = null;
|
|
_pauseTime = null;
|
|
_elapsedTime = Duration.zero;
|
|
});
|
|
}
|
|
|
|
void _recordLap() {
|
|
if (_isRunning) {
|
|
final now = DateTime.now();
|
|
setState(() {
|
|
// Store both timestamp and formatted duration for this lap
|
|
_laps.add('${now.toIso8601String()} - ${_formatDuration(_elapsedTime)}');
|
|
});
|
|
}
|
|
}
|
|
|
|
String _formatDuration(Duration duration) {
|
|
// Format the duration as ISO 8601 duration format without 0 values
|
|
final hours = duration.inHours;
|
|
final minutes = duration.inMinutes.remainder(60);
|
|
final seconds = duration.inSeconds.remainder(60);
|
|
|
|
// Round milliseconds to nearest 100ms for more consistent display
|
|
// This helps avoid floating point inconsistencies (0.201, 0.301, etc.)
|
|
final milliseconds = ((duration.inMilliseconds % 1000) / 100).round() * 100;
|
|
final formattedMs = (milliseconds ~/ 100).toString();
|
|
|
|
// Start with PT prefix
|
|
String result = 'PT';
|
|
|
|
// Only add parts that are non-zero (except if everything is zero, then show 0S)
|
|
if (hours > 0) {
|
|
result += '${hours}H';
|
|
}
|
|
|
|
if (minutes > 0 || hours > 0) {
|
|
result += '${minutes}M';
|
|
}
|
|
|
|
// Always include seconds with a single decimal place (tenth of a second)
|
|
result += '${seconds}.${formattedMs}S';
|
|
|
|
return result;
|
|
}
|
|
|
|
void _handleKeyEvent(RawKeyEvent event) {
|
|
if (event is RawKeyDownEvent) {
|
|
if (event.logicalKey == LogicalKeyboardKey.space) {
|
|
// Space: Start if paused, Lap if running
|
|
if (_isRunning) {
|
|
_recordLap();
|
|
} else {
|
|
_startTimer();
|
|
}
|
|
} else if (event.logicalKey == LogicalKeyboardKey.enter) {
|
|
// Enter: Stop if paused, Pause if running
|
|
if (_isRunning) {
|
|
_pauseTimer();
|
|
} else {
|
|
_stopTimer();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_focusNode.requestFocus();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_timer?.cancel();
|
|
_focusNode.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return RawKeyboardListener(
|
|
focusNode: _focusNode,
|
|
onKey: _handleKeyEvent,
|
|
autofocus: true,
|
|
child: Scaffold(
|
|
appBar: AppBar(
|
|
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
|
title: Text(widget.title),
|
|
),
|
|
body: Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
'Time Elapsed:',
|
|
style: Theme.of(context).textTheme.headlineSmall,
|
|
),
|
|
const SizedBox(height: 10),
|
|
Container(
|
|
width: 300, // Fixed width container to prevent "flashing"
|
|
alignment: Alignment.center,
|
|
child: Text(
|
|
_formatDuration(_elapsedTime),
|
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
|
fontFamily: 'monospace', // Use monospaced font for fixed-width characters
|
|
),
|
|
),
|
|
),
|
|
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: 10),
|
|
Text(
|
|
'Keyboard: SPACE for Start/Lap, ENTER for Pause/Stop',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
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]}'),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|