426 lines
12 KiB
Dart
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).toString().padLeft(3, '0')}: ${_laps[index]}'),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|