Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/build-apk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ jobs:
- name: Install dependencies
run: flutter pub get

- name: Check formatting
run: dart format --output=none --set-exit-if-changed lib test

- name: Run code analyzer
run: flutter analyze

Expand Down
27 changes: 24 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
* **Countdown Timer**:
- Focus presets: **Pomodoro (25m)**, **Short Break (5m)**, **Deep Work (45m)**, and **Power Nap (20m)**.
- Giant digital countdown with tactile **START / PAUSE / RESET** buttons.
- System bell chime and haptic feedback on completion.
- System alert sound and vibration (three pulses) when the timer finishes. The timer runs from an absolute end time, so it stays accurate if the app is paused or briefly backgrounded. There is no background notification: keep the app open (the screen stays on) to hear the alert.
* **Precision Stopwatch**:
- Millisecond precision (`MM:SS.ms`).
- Lap times recording with scrollable delta list.
Expand All @@ -51,6 +51,10 @@

## 📳 Immersion & Orientation

* **Screen stays on**: the display will not sleep while the clock is open (toggle *Keep Screen On* in the Design Studio).
* **Remembers your setup**: theme, font, display toggles, last mode, countdown target and timer length are restored on launch.
* **Screen-reader friendly**: each mode announces its time, and every control has a label.

* **Tap-to-Hide Immersion**: Tap anywhere on the background to hide the top bar, theme selector, and navigation bar—leaving only the pure glowing clock numbers against OLED black.
* **Adaptive Landscape Dock**: Rotating your device horizontally automatically transforms the layout into a widescreen bedside / desk stand clock with time on the left and focus telemetry on the right.

Expand Down Expand Up @@ -85,8 +89,22 @@ Every commit or version tag (`v*`) triggers [`.github/workflows/build-apk.yml`](

## 🚀 Building & Running Locally

### Project layout

```
lib/
main.dart app shell, gestures, keyboard shortcuts
logic/ pure timer / stopwatch / countdown logic (unit tested)
services/ saved settings, wake lock, alerts
theme/ 24 palettes and 14 fonts
views/ clock, countdown, focus timer and stopwatch screens
widgets/ Design Studio sheet and the control bars
test/ unit and widget tests
index.html standalone web live demo (separate from the Flutter app)
```

### Prerequisites
* Flutter SDK (3.22+)
* Flutter SDK (stable, Dart ^3.11.5 — see `pubspec.yaml`)
* Android SDK (API 34+) or an active Android device / emulator

```bash
Expand All @@ -100,7 +118,10 @@ flutter pub get
# 3. Run on connected device or emulator
flutter run

# 4. Build release APK
# 4. Run checks (same as CI)
flutter analyze && flutter test

# 5. Build release APK
flutter build apk --release
```

Expand Down
196 changes: 196 additions & 0 deletions lib/logic/time_logic.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import 'dart:async';

import 'package:flutter/foundation.dart';

/// Injectable wall clock so controllers can be tested deterministically.
typedef NowProvider = DateTime Function();

/// Remaining time to a countdown target, split into display units.
class CountdownParts {
final int days;
final int hours;
final int minutes;
final int seconds;
final bool expired;

const CountdownParts({
required this.days,
required this.hours,
required this.minutes,
required this.seconds,
required this.expired,
});
}

CountdownParts countdownParts(DateTime target, DateTime now) {
final diff = target.difference(now);
if (diff.isNegative || diff == Duration.zero) {
return const CountdownParts(
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
expired: true,
);
}
return CountdownParts(
days: diff.inDays,
hours: diff.inHours % 24,
minutes: diff.inMinutes % 60,
seconds: diff.inSeconds % 60,
expired: false,
);
}

/// Fraction of the local day that has elapsed (0.0 - 1.0).
double dayProgress(DateTime now) =>
(now.hour * 3600 + now.minute * 60 + now.second) / 86400;

String two(int v) => v.toString().padLeft(2, '0');

/// `MM:SS` for the focus timer.
String formatTimer(int seconds) => '${two(seconds ~/ 60)}:${two(seconds % 60)}';

/// Stopwatch milliseconds split as `MM:SS` and hundredths.
class StopwatchParts {
final String minutesSeconds;
final String hundredths;

const StopwatchParts(this.minutesSeconds, this.hundredths);

String get full => '$minutesSeconds.$hundredths';
}

StopwatchParts splitStopwatch(int milliseconds) {
final totalSeconds = milliseconds ~/ 1000;
return StopwatchParts(
'${two(totalSeconds ~/ 60)}:${two(totalSeconds % 60)}',
two((milliseconds % 1000) ~/ 10),
);
}

/// Countdown timer anchored to an absolute end time, so it stays accurate
/// when the app is backgrounded or the event loop is delayed.
class FocusTimerController {
FocusTimerController({
int totalSeconds = 25 * 60,
NowProvider? now,
this.onFinished,
}) : _now = now ?? DateTime.now,
_totalSeconds = totalSeconds,
remaining = ValueNotifier<int>(totalSeconds);

final NowProvider _now;
final VoidCallback? onFinished;

/// Whole seconds left on the clock.
final ValueNotifier<int> remaining;
final ValueNotifier<bool> running = ValueNotifier<bool>(false);

int _totalSeconds;
DateTime? _endsAt;
Timer? _ticker;

int get totalSeconds => _totalSeconds;

double get progress =>
_totalSeconds > 0 ? remaining.value / _totalSeconds : 0.0;

void toggle() {
if (running.value) {
_stop();
return;
}
if (remaining.value <= 0) remaining.value = _totalSeconds;
_endsAt = _now().add(Duration(seconds: remaining.value));
running.value = true;
_ticker = Timer.periodic(const Duration(milliseconds: 200), (_) => tick());
}

void reset([int? seconds]) {
_stop();
if (seconds != null) _totalSeconds = seconds;
remaining.value = _totalSeconds;
}

/// Recomputes the remaining time from the wall clock (also called on resume).
void tick() {
final endsAt = _endsAt;
if (endsAt == null) return;
final ms = endsAt.difference(_now()).inMilliseconds;
final secs = ms <= 0 ? 0 : (ms + 999) ~/ 1000;
remaining.value = secs;
if (secs == 0) {
_stop();
onFinished?.call();
}
}

void _stop() {
_ticker?.cancel();
_ticker = null;
_endsAt = null;
running.value = false;
}

void dispose() {
_ticker?.cancel();
remaining.dispose();
running.dispose();
}
}

/// Stopwatch with lap recording, driven by the wall clock.
class StopwatchController {
StopwatchController({NowProvider? now}) : _now = now ?? DateTime.now;

final NowProvider _now;

final ValueNotifier<int> elapsedMs = ValueNotifier<int>(0);
final ValueNotifier<bool> running = ValueNotifier<bool>(false);

/// Laps, newest first.
final ValueNotifier<List<int>> laps = ValueNotifier<List<int>>(const []);

DateTime? _startedAt;
Timer? _ticker;

void toggle() {
if (running.value) {
tick();
_ticker?.cancel();
_ticker = null;
running.value = false;
return;
}
_startedAt = _now().subtract(Duration(milliseconds: elapsedMs.value));
running.value = true;
_ticker = Timer.periodic(const Duration(milliseconds: 16), (_) => tick());
}

void lap() {
if (!running.value) return;
laps.value = [elapsedMs.value, ...laps.value];
}

void reset() {
_ticker?.cancel();
_ticker = null;
running.value = false;
elapsedMs.value = 0;
laps.value = const [];
}

void tick() {
final start = _startedAt;
if (start == null) return;
elapsedMs.value = _now().difference(start).inMilliseconds;
}

void dispose() {
_ticker?.cancel();
elapsedMs.dispose();
running.dispose();
laps.dispose();
}
}
Loading
Loading