Compare commits

...

11 Commits

Author SHA1 Message Date
eb870c4d49 Fix naming in release 2025-03-15 00:24:23 +01:00
897069c08b Leftpad the "Lap" text
So our lines of text don't dance
2025-03-15 00:13:47 +01:00
a1b577ef4c Fuck with size again 2025-03-15 00:12:45 +01:00
64895c12b9 FIX keyboard 2025-03-15 00:07:30 +01:00
7601cf3a22 Cleanup 2025-03-15 00:04:19 +01:00
8c7150a1ed It's just a timer 2025-03-15 00:02:39 +01:00
a29f090895 Fix deprecated keyboard controller 2025-03-15 00:00:51 +01:00
8640ad0499 Properly format this shit 2025-03-14 23:59:37 +01:00
e62c74b72c Add saving to and reading from a file
To make sure when our timer runs it runs
2025-03-14 23:55:42 +01:00
75454c6edc Make smaller window
Copied blindly from wrong project, oops!
2025-03-14 23:36:03 +01:00
997dd5b6b3 More often update please 2025-03-14 23:35:52 +01:00
6 changed files with 372 additions and 98 deletions

View File

@@ -1,6 +1,9 @@
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());
@@ -12,11 +15,20 @@ class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Simple Timer',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
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: 'Simple Timer'),
),
home: const MyHomePage(title: 'Just a timer'),
);
}
}
@@ -28,6 +40,25 @@ class MyHomePage extends StatefulWidget {
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;
@@ -36,6 +67,154 @@ class _MyHomePageState extends State<MyHomePage> {
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();
@@ -44,14 +223,17 @@ class _MyHomePageState extends State<MyHomePage> {
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: 100), (timer) {
_timer = Timer.periodic(const Duration(milliseconds: 16), (timer) {
setState(() {
_elapsedTime = DateTime.now().difference(_startTime!);
});
@@ -65,6 +247,7 @@ class _MyHomePageState extends State<MyHomePage> {
void _pauseTimer() {
_timer?.cancel();
_pauseTime = DateTime.now();
_addEvent('pause');
setState(() {
_isRunning = false;
});
@@ -72,64 +255,72 @@ class _MyHomePageState extends State<MyHomePage> {
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()} - ${_formatDuration(_elapsedTime)}');
_laps.add('${now.toIso8601String()} - ${_formatDurationForLap(_elapsedTime)}');
});
_saveTimerData();
}
}
String _formatDuration(Duration duration) {
// Format the duration as ISO 8601 duration format without 0 values
// 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);
// 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();
// Use padLeft to ensure consistent width with leading zeros
final secondsStr = seconds.toString().padLeft(2, '0');
final msStr = milliseconds.toString().padLeft(3, '0');
// Start with PT prefix
String result = 'PT';
// Only add parts that are non-zero (except if everything is zero, then show 0S)
// Build output - only show non-zero hours and minutes
StringBuffer result = StringBuffer();
if (hours > 0) {
result += '${hours}H';
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();
}
if (minutes > 0 || hours > 0) {
result += '${minutes}M';
// For lap recording - use the same format
String _formatDurationForLap(Duration duration) {
return _formatDuration(duration);
}
// 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) {
// 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) {
// 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
}
// Handle Enter key
else if (event.logicalKey == LogicalKeyboardKey.enter) {
if (_isRunning) {
_pauseTimer();
} else {
@@ -139,12 +330,6 @@ class _MyHomePageState extends State<MyHomePage> {
}
}
@override
void initState() {
super.initState();
_focusNode.requestFocus();
}
@override
void dispose() {
_timer?.cancel();
@@ -154,32 +339,42 @@ class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return RawKeyboardListener(
// Use Focus directly to capture key events
return Focus(
focusNode: _focusNode,
onKey: _handleKeyEvent,
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: Center(
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: [
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,
// 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),
@@ -202,11 +397,6 @@ class _MyHomePageState extends State<MyHomePage> {
),
],
),
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(
@@ -219,7 +409,7 @@ class _MyHomePageState extends State<MyHomePage> {
itemCount: _laps.length,
itemBuilder: (context, index) {
return ListTile(
title: Text('Lap ${index + 1}: ${_laps[index]}'),
title: Text('Lap ${(index + 1).toString().padLeft(3, '0')}: ${_laps[index]}'),
);
},
),
@@ -229,6 +419,7 @@ class _MyHomePageState extends State<MyHomePage> {
),
),
),
),
);
}
}

View File

@@ -5,6 +5,8 @@
import FlutterMacOS
import Foundation
import path_provider_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
}

View File

@@ -57,6 +57,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.2"
ffi:
dependency: transitive
description:
name: ffi
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
flutter:
dependency: "direct main"
description: flutter
@@ -139,6 +147,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "0ca7359dad67fd7063cb2892ab0c0737b2daafd807cf1acecd62374c8fae6c12"
url: "https://pub.dev"
source: hosted
version: "2.2.16"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
sky_engine:
dependency: transitive
description: flutter
@@ -208,6 +280,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "14.3.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
sdks:
dart: ">=3.7.0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"
flutter: ">=3.27.0"

View File

@@ -34,6 +34,7 @@ dependencies:
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
path_provider: ^2.1.2 # For accessing local file system
dev_dependencies:
flutter_test:

View File

@@ -23,8 +23,8 @@ echo "Creating a release..."
TOKEN="$GITEA_API_KEY"
GITEA="https://git.site.quack-lab.dev"
REPO="dave/flutter-simple-timer"
ZIP="calorie-counter-${TAG}.zip"
APK="calorie-counter-${TAG}.apk"
ZIP="simple-timer-${TAG}.zip"
APK="simple-timer-${TAG}.apk"
# Create a release
RELEASE_RESPONSE=$(curl -s -X POST \
-H "Authorization: token $TOKEN" \

View File

@@ -25,10 +25,10 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
project.set_dart_entrypoint_arguments(std::move(command_line_arguments));
FlutterWindow window(project);
Win32Window::Size size(1920, 1080);
Win32Window::Size size(1200, 800);
Win32Window::Point origin((GetSystemMetrics(SM_CXSCREEN) - size.width) / 2,
(GetSystemMetrics(SM_CYSCREEN) - size.height) / 2);
if (!window.Create(L"simple_timer", origin, size)) {
if (!window.Create(L"Just a timer", origin, size)) {
return EXIT_FAILURE;
}
window.SetQuitOnClose(true);