diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index 5a63d016..1a5ce7c0 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -1,11 +1,18 @@ + + + + + + if (call.method == "pick") { if (folderResult != null) { @@ -95,6 +96,21 @@ class MainActivity : FlutterActivity() { } } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + TimerNotifications.opened(intent) + } + + override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) { + TimerNotifications.detach(flutterEngine) + super.cleanUpFlutterEngine(flutterEngine) + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == TimerNotifications.permissionRequest) TimerNotifications.refresh(this, true) + } + @Deprecated("Activity result bridge for Flutter folder access") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (requestCode != folderRequest) { diff --git a/app/android/app/src/main/kotlin/com/papyrus/reader/TimerNotifications.kt b/app/android/app/src/main/kotlin/com/papyrus/reader/TimerNotifications.kt new file mode 100644 index 00000000..1d64c3b1 --- /dev/null +++ b/app/android/app/src/main/kotlin/com/papyrus/reader/TimerNotifications.kt @@ -0,0 +1,318 @@ +package com.papyrus.reader + +import android.Manifest +import android.app.Activity +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel +import org.json.JSONArray +import org.json.JSONObject +import java.util.UUID + +/** Native journal survives suspension and process death without a second database writer. */ +internal class TimerNotificationStore(context: Context) { + private val prefs = context.getSharedPreferences("reading-timer-notification", Context.MODE_PRIVATE) + + fun read(): JSONObject = JSONObject(prefs.getString("state", null) ?: "{}") + + private fun write(state: JSONObject) { + check(prefs.edit().putString("state", state.toString()).commit()) { "Timer action could not be saved" } + } + + fun publish(snapshot: JSONObject?) { + val state = read() + if (state.optJSONArray("actions")?.length() ?: 0 > 0) return + write(JSONObject().put("base", snapshot ?: JSONObject.NULL).put("actions", JSONArray())) + } + + fun clear() = write(JSONObject()) + + fun pending(): JSONObject? { + val state = read() + val actions = state.optJSONArray("actions") ?: return null + if (actions.length() == 0) return null + val base = state.getJSONObject("base") + return JSONObject().put("sessionId", base.getString("id")).put("version", base.getString("version")) + .put("through", actions.getJSONObject(actions.length() - 1).getString("id")).put("actions", actions) + } + + fun acknowledge(through: String, snapshot: JSONObject?) { + val state = read() + val actions = state.optJSONArray("actions") ?: return + val remaining = JSONArray() + var found = false + for (index in 0 until actions.length()) { + val action = actions.getJSONObject(index) + if (found) remaining.put(action) + if (action.getString("id") == through) found = true + } + if (!found) return + // A different session invalidates controls from the previous notification. + val same = snapshot?.optString("id") == state.optJSONObject("base")?.optString("id") + write(JSONObject().put("base", snapshot ?: JSONObject.NULL) + .put("actions", if (same) remaining else JSONArray())) + } + + fun record(id: String?, token: String?, kind: String) { + val state = read() + val view = view(state) ?: return + if (view.optString("id") != id || view.optString("token") != token) return + val phase = view.optString("phase") + if (kind == "pause" && phase != "running") return + if (kind == "resume" && phase != "paused") return + if (kind !in listOf("pause", "resume", "finish")) return + val actions = state.optJSONArray("actions") ?: JSONArray() + check(actions.length() < 256) { "Open Papyrus to sync your timer controls" } + actions.put(JSONObject().put("id", UUID.randomUUID().toString()).put("kind", kind) + .put("atMs", System.currentTimeMillis())) + state.put("actions", actions) + write(state) + } + + fun view(state: JSONObject = read()): JSONObject? { + val base = state.optJSONObject("base") ?: return null + val result = JSONObject(base.toString()).put("token", base.getString("version")) + val actions = state.optJSONArray("actions") ?: JSONArray() + for (index in 0 until actions.length()) { + val action = actions.getJSONObject(index) + val at = action.getLong("atMs") + val recorded = elapsed(result, at) + result.put("recordedMs", recorded).put("runningSinceMs", JSONObject.NULL) + .put("token", action.getString("id")) + when (action.getString("kind")) { + "resume" -> result.put("phase", "running").put("runningSinceMs", at) + "pause" -> result.put("phase", "paused") + "finish" -> return null + } + } + if (!result.isNull("targetMs") && elapsed(result) >= result.getLong("targetMs")) { + result.put("phase", "finished").put("recordedMs", result.getLong("targetMs")) + .put("runningSinceMs", JSONObject.NULL) + } + return result + } + + companion object { + fun elapsed(view: JSONObject, now: Long = System.currentTimeMillis()): Long { + val recorded = view.optLong("recordedMs") + val elapsed = recorded + if (view.isNull("runningSinceMs")) 0 else (now - view.getLong("runningSinceMs")).coerceAtLeast(0) + return if (view.isNull("targetMs")) elapsed else elapsed.coerceAtMost(view.getLong("targetMs")) + } + } +} + +internal object TimerNotifications { + const val channelId = "reading_timer" + const val notificationId = 7143 + const val permissionRequest = 7144 + const val openExtra = "papyrus.open_timer" + private var channel: MethodChannel? = null + private var engine: FlutterEngine? = null + private var openPending = false + + fun configure(activity: Activity, engine: FlutterEngine) { + this.engine = engine + channel = MethodChannel(engine.dartExecutor.binaryMessenger, "papyrus/reading_timer").also { bridge -> + bridge.setMethodCallHandler { call, result -> + try { + val store = TimerNotificationStore(activity) + val args = call.arguments as? Map<*, *> + val snapshot = (args?.get("snapshot") as? Map<*, *>)?.let { JSONObject(it) } + when (call.method) { + "pending" -> { + if (openPending) { + openPending = false + Handler(Looper.getMainLooper()).post { bridge.invokeMethod("open", null) } + } + result.success(store.pending()?.toMap()) + } + "publish" -> { + store.publish(snapshot) + refresh(activity, args?.get("foreground") == true) + result.success(null) + } + "acknowledge" -> { + store.acknowledge(args?.get("through") as String, snapshot) + refresh(activity, false) + result.success(null) + } + "clear" -> { + store.clear() + refresh(activity, false) + result.success(null) + } + else -> result.notImplemented() + } + } catch (error: Exception) { + result.error("notification", error.message, null) + } + } + } + opened(activity.intent) + } + + fun detach(engine: FlutterEngine) { + if (this.engine === engine) { + channel?.setMethodCallHandler(null) + channel = null + this.engine = null + } + } + + fun opened(intent: Intent?) { + if (intent?.getBooleanExtra(openExtra, false) == true) { + openPending = true + channel?.invokeMethod("changed", null) + } + } + + fun changed() = channel?.invokeMethod("changed", null) + + fun refresh(context: Context, foreground: Boolean) { + val view = TimerNotificationStore(context).view() + if (view == null) { + context.stopService(Intent(context, ReadingTimerService::class.java)) + NotificationManagerCompat.from(context).cancel(notificationId) + return + } + if (Build.VERSION.SDK_INT >= 33 && ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + val prefs = context.getSharedPreferences("reading-timer-notification", Context.MODE_PRIVATE) + if (foreground && context is Activity && !prefs.getBoolean("permissionAsked", false)) { + prefs.edit().putBoolean("permissionAsked", true).apply() + ActivityCompat.requestPermissions(context, arrayOf(Manifest.permission.POST_NOTIFICATIONS), permissionRequest) + } + return + } + if (ReadingTimerService.current != null) { + ReadingTimerService.current?.refresh() + } else if (foreground) { + ContextCompat.startForegroundService(context, Intent(context, ReadingTimerService::class.java)) + } + } + + private fun JSONObject.toMap(): Map = keys().asSequence().associateWith { key -> + when (val value = get(key)) { + JSONObject.NULL -> null + is JSONObject -> value.toMap() + is JSONArray -> (0 until value.length()).map { value.getJSONObject(it).toMap() } + else -> value + } + } +} + +class ReadingTimerService : Service() { + private val handler = Handler(Looper.getMainLooper()) + private val update = Runnable { refresh() } + private val wake = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) = refresh() + } + + override fun onCreate() { + super.onCreate() + current = this + ContextCompat.registerReceiver(this, wake, IntentFilter(Intent.ACTION_SCREEN_ON), ContextCompat.RECEIVER_NOT_EXPORTED) + if (Build.VERSION.SDK_INT >= 26) { + getSystemService(NotificationManager::class.java).createNotificationChannel( + NotificationChannel(TimerNotifications.channelId, "Reading timer", NotificationManager.IMPORTANCE_LOW) + .apply { description = "Active reading sessions and timer controls"; setSound(null, null) } + ) + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + try { + intent?.getStringExtra("kind")?.let { + TimerNotificationStore(this).record(intent.getStringExtra("id"), intent.getStringExtra("token"), it) + TimerNotifications.changed() + } + refresh() + } catch (_: Exception) { + // A failed durable write leaves the previous timer and its controls intact. + refresh() + } + return START_STICKY + } + + fun refresh() { + handler.removeCallbacks(update) + val view = TimerNotificationStore(this).view() + if (view == null) { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + return + } + val elapsed = TimerNotificationStore.elapsed(view) + val running = view.getString("phase") == "running" + val finished = view.getString("phase") == "finished" + val countdown = !view.isNull("targetMs") + val shown = if (countdown) (view.getLong("targetMs") - elapsed).coerceAtLeast(0) else elapsed + val open = PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + .putExtra(TimerNotifications.openExtra, true), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + val builder = NotificationCompat.Builder(this, TimerNotifications.channelId) + .setSmallIcon(R.drawable.ic_reading_timer).setContentTitle(view.getString("title")) + .setContentText(if (finished) (if (countdown && shown == 0L) "Reading goal reached" else "Finished") else if (running) "Reading" else "Paused ยท ${format(shown)}") + .setContentIntent(open).setOnlyAlertOnce(true).setOngoing(!finished).setSilent(true) + .setCategory(NotificationCompat.CATEGORY_STOPWATCH).setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .setShowWhen(running).setUsesChronometer(running).setChronometerCountDown(countdown) + .setWhen(if (countdown) System.currentTimeMillis() + shown else System.currentTimeMillis() - elapsed) + if (!finished) builder.addAction(0, if (running) "Pause" else "Resume", action(view, if (running) "pause" else "resume")) + builder.addAction(0, if (finished) "Save" else "Finish", action(view, "finish")) + val notification = builder.build() + if (Build.VERSION.SDK_INT >= 34) { + startForeground(TimerNotifications.notificationId, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE) + } else { + startForeground(TimerNotifications.notificationId, notification) + } + if (!running) { + stopForeground(STOP_FOREGROUND_DETACH) + stopSelf() + } else if (running && countdown) { + handler.postDelayed(update, shown.coerceAtLeast(1)) + } + } + + private fun action(view: JSONObject, kind: String): PendingIntent { + val intent = Intent(this, ReadingTimerService::class.java).putExtra("kind", kind) + .putExtra("id", view.getString("id")).putExtra("token", view.getString("token")) + val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + return if (Build.VERSION.SDK_INT >= 26) PendingIntent.getForegroundService(this, kind.hashCode(), intent, flags) + else PendingIntent.getService(this, kind.hashCode(), intent, flags) + } + + override fun onDestroy() { + handler.removeCallbacksAndMessages(null) + unregisterReceiver(wake) + if (current === this) current = null + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + companion object { + internal var current: ReadingTimerService? = null + private fun format(milliseconds: Long): String { + val seconds = milliseconds / 1000 + return if (seconds >= 3600) "%d:%02d:%02d".format(seconds / 3600, seconds / 60 % 60, seconds % 60) + else "%02d:%02d".format(seconds / 60, seconds % 60) + } + } +} diff --git a/app/android/app/src/main/res/drawable/ic_reading_timer.xml b/app/android/app/src/main/res/drawable/ic_reading_timer.xml new file mode 100644 index 00000000..dc861361 --- /dev/null +++ b/app/android/app/src/main/res/drawable/ic_reading_timer.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/integration_test/powersync_timer_integration_test.dart b/app/integration_test/powersync_timer_integration_test.dart new file mode 100644 index 00000000..51a3c9a2 --- /dev/null +++ b/app/integration_test/powersync_timer_integration_test.dart @@ -0,0 +1,195 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:papyrus/auth/auth_api_client.dart'; +import 'package:papyrus/auth/auth_repository.dart'; +import 'package:papyrus/auth/papyrus_api_config.dart'; +import 'package:papyrus/auth/token_store.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/data/repositories/reading_timer_repository.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/powersync/papyrus_powersync_connector.dart'; +import 'package:papyrus/powersync/library_database.dart'; +import 'package:papyrus/powersync/powersync_service.dart'; +import 'package:papyrus/providers/reading_timer_provider.dart'; +import 'package:papyrus/timer/reading_timer.dart'; +import 'package:uuid/uuid.dart'; + +import 'powersync_books_integration_test.dart' show MemoryRefreshTokenStorage, waitForBook; + +const runIntegration = bool.fromEnvironment('RUN_POWERSYNC_INTEGRATION'); + +Future waitUntil(Future Function() condition, String description) async { + final deadline = DateTime.now().add(const Duration(seconds: 30)); + + while (DateTime.now().isBefore(deadline)) { + if (await condition()) { + return; + } + + await Future.delayed(const Duration(milliseconds: 100)); + } + + fail('Timed out: $description'); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets( + 'PowerSync timer handoff, offline conflicts, save once and isolation', + (tester) async { + final config = PapyrusApiConfig.fromEnvironment(); + final root = await Directory.systemTemp.createTemp('papyrus-timer-sync-'); + final email = 'timer-${DateTime.now().microsecondsSinceEpoch}@example.com'; + const password = 'SecureP@ss123'; + final accounts = []; + final services = []; + final stores = []; + final providers = []; + + AuthRepository auth() => AuthRepository( + apiClient: AuthApiClient(config: config), + tokenStore: TokenStore(MemoryRefreshTokenStorage()), + ); + + Future device(AuthRepository account, String user, String label) async { + final service = PapyrusPowerSyncService( + connectorFactory: () => PapyrusPowerSyncConnector(authRepository: account, config: config), + trackingCapability: () async => 2, + pathResolver: (mode, profile, user) async => '${root.path}/$label-${mode.name}-$user.db', + ); + + services.add(service); + await service.activateAuthenticated(user); + await waitUntil(() async => service.syncState.connected, '$label connects'); + return service; + } + + Future watch(PapyrusPowerSyncService service) async { + final store = DataStore(); + stores.add(store); + await store.attachBookRepository(service); + final provider = ReadingTimerProvider(store, tick: false); + providers.add(provider); + await waitUntil(() async => provider.available, 'timer repository loads'); + return provider; + } + + ReadingTimerRepository timers(PapyrusPowerSyncService service) => + service.trackingRepository as ReadingTimerRepository; + + try { + final browserAuth = auth(); + final owner = await browserAuth.register( + email: email, + password: password, + displayName: 'Timer test', + clientType: 'desktop', + ); + accounts.add(browserAuth); + final phoneAuth = auth(); + await phoneAuth.login(email: email, password: password, clientType: 'desktop'); + final otherAuth = auth(); + final otherOwner = await otherAuth.register( + email: 'other-$email', + password: password, + displayName: 'Other', + clientType: 'desktop', + ); + accounts.add(otherAuth); + final browser = await device(browserAuth, owner.user.userId, 'browser'); + final phone = await device(phoneAuth, owner.user.userId, 'phone'); + final other = await device(otherAuth, otherOwner.user.userId, 'other'); + final browserTimer = await watch(browser); + final phoneTimer = await watch(phone); + final book = Book( + id: const Uuid().v4(), + title: 'Synced timer', + author: 'Papyrus', + addedAt: DateTime.now().toUtc(), + ); + await browser.upsert(book); + await waitForBook(phone, book.id); + final start = DateTime.now().toUtc().subtract(const Duration(minutes: 2)); + final initial = ReadingTimer.start(book, start, 'browser', [], targetSeconds: 60); + await timers(browser).writeTimer(initial); + await waitUntil(() async => phoneTimer.session?.id == initial.id, 'countdown reaches second device'); + expect(phoneTimer.elapsed, const Duration(minutes: 1)); + expect(phoneTimer.goalReached, isTrue); + expect(await timers(other).loadTimer(), isNull); + expect(await phoneTimer.resume(), isTrue); + await waitUntil( + () async => browserTimer.session?.version == phoneTimer.session?.version, + 'resume reaches first device', + ); + expect(browserTimer.session!.targetSeconds, isNull); + expect(browserTimer.running, isTrue); + + await browser.setOnline(false); + expect(await browserTimer.pause(), isTrue); + final offlineVersion = browserTimer.session!.version; + expect(await phoneTimer.pause(), isTrue); + final acceptedVersion = phoneTimer.session!.version; + await waitUntil(() async => !phone.syncState.hasPendingWrites, 'online pause uploads'); + await browser.setOnline(true); + await waitUntil( + () async => browserTimer.session?.version == acceptedVersion, + 'offline stale branch reconciles', + ); + expect(browserTimer.session!.version, isNot(offlineVersion)); + expect(browserTimer.elapsed, phoneTimer.elapsed); + + await browser.setOnline(false); + await phone.setOnline(false); + expect(await browserTimer.save(currentPage: 12), isTrue); + expect(await phoneTimer.save(currentPage: 24), isTrue); + await browser.setOnline(true); + await waitUntil(() async => !browser.syncState.hasPendingWrites, 'first save uploads'); + await phone.setOnline(true); + await waitUntil(() async => !phone.syncState.hasPendingWrites, 'second save acknowledged'); + await waitForBook(phone, book.id); + await waitUntil(() async => (await phone.getById(book.id))?.currentPage == 12, 'accepted progress reconciles'); + await waitUntil( + () async => (await (phone.trackingRepository as LibraryDatabase).snapshot()).activities.isNotEmpty, + 'saved history replicates', + ); + final firstHistory = (await (browser.trackingRepository as LibraryDatabase).snapshot()).activities; + final secondHistory = (await (phone.trackingRepository as LibraryDatabase).snapshot()).activities; + expect(firstHistory.map((entry) => entry.id).toSet(), secondHistory.map((entry) => entry.id).toSet()); + expect(firstHistory.map((entry) => entry.sessionId).toSet(), {initial.id}); + expect(firstHistory.length, 2); + expect(browserTimer.session, isNull); + expect(phoneTimer.session, isNull); + expect(await timers(other).loadTimer(), isNull); + expect((await (other.trackingRepository as LibraryDatabase).snapshot()).activities, isEmpty); + + expect(await phoneTimer.start(book), isTrue); + await waitUntil(() async => browserTimer.session?.id == phoneTimer.session?.id, 'new session syncs'); + expect(await browserTimer.discard(), isTrue); + await waitUntil(() async => phoneTimer.session == null, 'reset clears both devices'); + } finally { + for (final provider in providers) { + provider.dispose(); + } + + for (final store in stores) { + store.dispose(); + } + + for (final service in services) { + await service.close(); + } + + for (final account in accounts) { + await account.deleteAccount(); + } + + await root.delete(recursive: true); + } + }, + skip: !runIntegration, + timeout: const Timeout(Duration(minutes: 4)), + ); +} diff --git a/app/integration_test/support/timer_notification_app.dart b/app/integration_test/support/timer_notification_app.dart new file mode 100644 index 00000000..dd0ef43d --- /dev/null +++ b/app/integration_test/support/timer_notification_app.dart @@ -0,0 +1,62 @@ +// Native notification test host. Uses synthetic data and no user library. +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +void main() => runApp(const MaterialApp(home: TimerNotificationTestHost())); + +class TimerNotificationTestHost extends StatefulWidget { + const TimerNotificationTestHost({super.key}); + + @override + State createState() => _TimerNotificationTestHostState(); +} + +class _TimerNotificationTestHostState extends State { + static const channel = MethodChannel('papyrus/reading_timer'); + String result = ''; + + @override + void initState() { + super.initState(); + channel.setMethodCallHandler((call) async => readActions()); + } + + Future readActions() async { + final pending = await channel.invokeMethod('pending'); + if (mounted) setState(() => result = jsonEncode(pending)); + } + + Future start(bool countdown) async { + await channel.invokeMethod('clear'); + await channel.invokeMethod('publish', { + 'foreground': true, + 'snapshot': { + 'id': 'notification-native-test', + 'version': 'initial', + 'title': 'A Wizard of Earthsea', + 'phase': 'running', + 'recordedMs': 0, + 'runningSinceMs': DateTime.now().millisecondsSinceEpoch, + 'targetMs': countdown ? 60000 : null, + }, + }); + await readActions(); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('Timer notification test')), + body: ListView( + padding: const EdgeInsets.all(24), + children: [ + ElevatedButton(onPressed: () => start(false), child: const Text('Show stopwatch')), + ElevatedButton(onPressed: () => start(true), child: const Text('Show countdown')), + ElevatedButton(onPressed: readActions, child: const Text('Read actions')), + ElevatedButton(onPressed: () => channel.invokeMethod('clear'), child: const Text('Clear')), + Text(result), + ], + ), + ); +} diff --git a/app/ios/Runner.xcodeproj/project.pbxproj b/app/ios/Runner.xcodeproj/project.pbxproj index ffc6375e..453f0e2b 100644 --- a/app/ios/Runner.xcodeproj/project.pbxproj +++ b/app/ios/Runner.xcodeproj/project.pbxproj @@ -11,15 +11,28 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 3CA77AF42BB5232AA187BDF0 /* TimerActivityBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71DAFC778B73A155B06DD63 /* TimerActivityBridge.swift */; }; + 652591C9846567F3C516D822 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BB81FA02508ACAF38703F4AE /* Foundation.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 868FF47BFAB5136C34AF6BE4 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9F8FEBE2FA3B4198747219C3 /* Pods_RunnerTests.framework */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + C563601939E0871D48137FD6 /* TimerActivityState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47A4B5EE31007F3B419E6F67 /* TimerActivityState.swift */; }; + C7718ECD87F3B481BE52359F /* ReadingTimerWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 461635AC1629FD038C3BCB37 /* ReadingTimerWidget.swift */; }; + D44CFA262E7B6F5B1839E7FA /* TimerActivityState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47A4B5EE31007F3B419E6F67 /* TimerActivityState.swift */; }; + E65D2B6542AE4DB4126F8453 /* ReadingTimerActivity.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 9A3A1B82A863A588BDD0F91A /* ReadingTimerActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 269D0DA8363325EEF62DEA71 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 16ED858D9EF44D28401EF882; + remoteInfo = ReadingTimerActivity; + }; 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 97C146E61CF9000F007C117D /* Project object */; @@ -30,6 +43,17 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + 5779A4CA60B6A50B23EDCB0D /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + E65D2B6542AE4DB4126F8453 /* ReadingTimerActivity.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -49,11 +73,14 @@ 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 4607D6A26A6B2888B3189E77 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 461635AC1629FD038C3BCB37 /* ReadingTimerWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReadingTimerWidget.swift; sourceTree = ""; }; + 47A4B5EE31007F3B419E6F67 /* TimerActivityState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TimerActivityState.swift; sourceTree = ""; }; 49B6ECE6CE06BC06E01F8D95 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 86F202F71D48A4136C559484 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 8A89C5201BBF198124CB9C3A /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; @@ -62,11 +89,15 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 9A3A1B82A863A588BDD0F91A /* ReadingTimerActivity.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ReadingTimerActivity.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 9F8FEBE2FA3B4198747219C3 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; A9CA2F0011A4F0F310447163 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; B80D0741A811213178C7AB3B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + BB81FA02508ACAF38703F4AE /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; BE1400E849E2F285D0A2A8EE /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; C7981B0A3A6F9682FF016C05 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + F71DAFC778B73A155B06DD63 /* TimerActivityBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TimerActivityBridge.swift; sourceTree = ""; }; + FE6338B763C834842B5F3A73 /* Config.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -78,6 +109,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 3B741A814AF7DE929F3B009C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 652591C9846567F3C516D822 /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -90,6 +129,18 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 054013DEBA04F4222784D3F3 /* TimerActivity */ = { + isa = PBXGroup; + children = ( + 47A4B5EE31007F3B419E6F67 /* TimerActivityState.swift */, + 461635AC1629FD038C3BCB37 /* ReadingTimerWidget.swift */, + 86F202F71D48A4136C559484 /* Info.plist */, + FE6338B763C834842B5F3A73 /* Config.xcconfig */, + ); + name = TimerActivity; + path = TimerActivity; + sourceTree = ""; + }; 331C8082294A63A400263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( @@ -98,6 +149,14 @@ path = RunnerTests; sourceTree = ""; }; + 3668C4FCB9C5A01536E2DB31 /* iOS */ = { + isa = PBXGroup; + children = ( + BB81FA02508ACAF38703F4AE /* Foundation.framework */, + ); + name = iOS; + sourceTree = ""; + }; 3DD4C4359594D66869C92A4F /* Pods */ = { isa = PBXGroup; children = ( @@ -133,6 +192,7 @@ 331C8082294A63A400263BE5 /* RunnerTests */, 3DD4C4359594D66869C92A4F /* Pods */, DE2BE2CEC624568007925435 /* Frameworks */, + 054013DEBA04F4222784D3F3 /* TimerActivity */, ); sourceTree = ""; }; @@ -141,6 +201,7 @@ children = ( 97C146EE1CF9000F007C117D /* Runner.app */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + 9A3A1B82A863A588BDD0F91A /* ReadingTimerActivity.appex */, ); name = Products; sourceTree = ""; @@ -156,6 +217,7 @@ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + F71DAFC778B73A155B06DD63 /* TimerActivityBridge.swift */, ); path = Runner; sourceTree = ""; @@ -165,6 +227,7 @@ children = ( B80D0741A811213178C7AB3B /* Pods_Runner.framework */, 9F8FEBE2FA3B4198747219C3 /* Pods_RunnerTests.framework */, + 3668C4FCB9C5A01536E2DB31 /* iOS */, ); name = Frameworks; sourceTree = ""; @@ -172,6 +235,23 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 16ED858D9EF44D28401EF882 /* ReadingTimerActivity */ = { + isa = PBXNativeTarget; + buildConfigurationList = C8DFCAC5A101D396EC8409B1 /* Build configuration list for PBXNativeTarget "ReadingTimerActivity" */; + buildPhases = ( + D27B8C6815F4723F489969C3 /* Sources */, + 3B741A814AF7DE929F3B009C /* Frameworks */, + 217AE57C9E9E71C5C0187858 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ReadingTimerActivity; + productName = ReadingTimerActivity; + productReference = 9A3A1B82A863A588BDD0F91A /* ReadingTimerActivity.appex */; + productType = "com.apple.product-type.app-extension"; + }; 331C8080294A63A400263BE5 /* RunnerTests */ = { isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; @@ -196,6 +276,7 @@ buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( 1D4DD9B09CAF9E29BC677E7E /* [CP] Check Pods Manifest.lock */, + 5779A4CA60B6A50B23EDCB0D /* Embed App Extensions */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, @@ -207,6 +288,7 @@ buildRules = ( ); dependencies = ( + 88EC704D83037C65469AC74E /* PBXTargetDependency */, ); name = Runner; packageProductDependencies = ( @@ -254,11 +336,19 @@ targets = ( 97C146ED1CF9000F007C117D /* Runner */, 331C8080294A63A400263BE5 /* RunnerTests */, + 16ED858D9EF44D28401EF882 /* ReadingTimerActivity */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 217AE57C9E9E71C5C0187858 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 331C807F294A63A400263BE5 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -389,6 +479,17 @@ files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + C563601939E0871D48137FD6 /* TimerActivityState.swift in Sources */, + 3CA77AF42BB5232AA187BDF0 /* TimerActivityBridge.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D27B8C6815F4723F489969C3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D44CFA262E7B6F5B1839E7FA /* TimerActivityState.swift in Sources */, + C7718ECD87F3B481BE52359F /* ReadingTimerWidget.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -400,6 +501,12 @@ target = 97C146ED1CF9000F007C117D /* Runner */; targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; }; + 88EC704D83037C65469AC74E /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = ReadingTimerActivity; + target = 16ED858D9EF44D28401EF882 /* ReadingTimerActivity */; + targetProxy = 269D0DA8363325EEF62DEA71 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -545,6 +652,33 @@ }; name = Profile; }; + 67DCFFAA8AE0DFF3218CBAE4 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = FE6338B763C834842B5F3A73 /* Config.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TimerActivity/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = com.papyrus.papyrus.ReadingTimerActivity; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -699,6 +833,59 @@ }; name = Release; }; + A982E8604D4588DFEDC7DF22 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = FE6338B763C834842B5F3A73 /* Config.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TimerActivity/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = com.papyrus.papyrus.ReadingTimerActivity; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + D5D9F78CDB84B0428AC48AD7 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = FE6338B763C834842B5F3A73 /* Config.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TimerActivity/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = com.papyrus.papyrus.ReadingTimerActivity; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -732,6 +919,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + C8DFCAC5A101D396EC8409B1 /* Build configuration list for PBXNativeTarget "ReadingTimerActivity" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 67DCFFAA8AE0DFF3218CBAE4 /* Release */, + A982E8604D4588DFEDC7DF22 /* Debug */, + D5D9F78CDB84B0428AC48AD7 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ diff --git a/app/ios/Runner/AppDelegate.swift b/app/ios/Runner/AppDelegate.swift index 79a628ff..47118eb8 100644 --- a/app/ios/Runner/AppDelegate.swift +++ b/app/ios/Runner/AppDelegate.swift @@ -5,6 +5,7 @@ import UniformTypeIdentifiers @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { private var folderAccess: PapyrusFolderAccess? + private var timerActivity: TimerActivityBridge? override func application( _ application: UIApplication, @@ -15,6 +16,9 @@ import UniformTypeIdentifiers func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "PapyrusTimer") { + timerActivity = TimerActivityBridge(messenger: registrar.messenger()) + } if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "PapyrusFolders") { folderAccess = PapyrusFolderAccess(messenger: registrar.messenger(), presenter: window?.rootViewController) } diff --git a/app/ios/Runner/Info.plist b/app/ios/Runner/Info.plist index 92bfe327..366bcdc8 100644 --- a/app/ios/Runner/Info.plist +++ b/app/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + NSSupportsLiveActivities + CADisableMinimumFrameDurationOnPhone CFBundleDevelopmentRegion diff --git a/app/ios/Runner/TimerActivityBridge.swift b/app/ios/Runner/TimerActivityBridge.swift new file mode 100644 index 00000000..794c5f16 --- /dev/null +++ b/app/ios/Runner/TimerActivityBridge.swift @@ -0,0 +1,57 @@ +import Flutter +import UIKit +import ActivityKit + +@MainActor +final class TimerActivityBridge { + private let channel: FlutterMethodChannel + private var observer: NSObjectProtocol? + + init(messenger: FlutterBinaryMessenger) { + channel = FlutterMethodChannel(name: "papyrus/reading_timer", binaryMessenger: messenger) + observer = NotificationCenter.default.addObserver(forName: Notification.Name("PapyrusTimerChanged"), object: nil, queue: .main) { [weak self] _ in + self?.channel.invokeMethod("changed", arguments: nil) + } + channel.setMethodCallHandler { [weak self] call, result in + Task { @MainActor in + guard self != nil else { result(nil); return } + do { + var journal = try TimerJournal.load() + let arguments = call.arguments as? [String: Any] ?? [:] + let snapshot = try (arguments["snapshot"] as? [String: Any]).map { + try JSONDecoder().decode(TimerSnapshot.self, from: JSONSerialization.data(withJSONObject: $0)) + } + switch call.method { + case "pending": + if let base = journal.base, let last = journal.actions.last { + let actions = try JSONSerialization.jsonObject(with: JSONEncoder().encode(journal.actions)) + result(["sessionId": base.id, "version": base.version, "through": last.id, "actions": actions]) + } else { result(nil) } + return + case "publish": + if journal.actions.isEmpty { + journal.base = snapshot + try journal.save() + } + case "acknowledge": + if let through = arguments["through"] as? String { + try journal.acknowledge(through: through, snapshot: snapshot) + } + case "clear": try TimerJournal().save() + default: result(FlutterMethodNotImplemented); return + } + if #available(iOS 16.2, *) { + try await TimerLiveActivity.refresh(allowStart: arguments["foreground"] as? Bool == true && UIApplication.shared.applicationState == .active) + } + result(nil) + } catch { + result(FlutterError(code: "notification", message: error.localizedDescription, details: nil)) + } + } + } + } + + deinit { + if let observer { NotificationCenter.default.removeObserver(observer) } + } +} diff --git a/app/ios/RunnerTests/RunnerTests.swift b/app/ios/RunnerTests/RunnerTests.swift index 86a7c3b1..2bf2c1fb 100644 --- a/app/ios/RunnerTests/RunnerTests.swift +++ b/app/ios/RunnerTests/RunnerTests.swift @@ -1,12 +1,69 @@ -import Flutter -import UIKit +import ActivityKit import XCTest +@testable import Runner +@MainActor class RunnerTests: XCTestCase { + private func snapshot() -> TimerSnapshot { + TimerSnapshot(id: "native-test", version: "v1", title: "A Wizard of Earthsea", phase: "running", + recordedMs: 0, runningSinceMs: Int64(Date().timeIntervalSince1970 * 1000) - 30_000, + targetMs: 60_000) + } - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. - } + func testJournalSurvivesReloadAndRejectsStaleControls() throws { + var journal = TimerJournal(base: snapshot()) + try journal.save() + try journal.record(sessionId: "native-test", token: "v1", kind: "pause") + journal = try TimerJournal.load() + XCTAssertEqual(journal.actions.count, 1) + XCTAssertEqual(journal.view()?.phase, "paused") + XCTAssertNil(journal.view()?.runningSinceMs) + XCTAssertGreaterThanOrEqual(journal.view()!.recordedMs, 30_000) + try journal.record(sessionId: "native-test", token: "v1", kind: "finish") + XCTAssertEqual(journal.actions.count, 1) + try TimerJournal().save() + } + func testAcknowledgementPreservesActionsAddedWhileDartWasWriting() throws { + var journal = TimerJournal(base: snapshot()) + try journal.record(sessionId: "native-test", token: "v1", kind: "pause") + let pause = journal.actions[0] + var committed = journal.view()! + committed.version = pause.id + committed.token = nil + try journal.record(sessionId: "native-test", token: pause.id, kind: "resume") + try journal.acknowledge(through: pause.id, snapshot: committed) + journal = try TimerJournal.load() + XCTAssertEqual(journal.actions.count, 1) + XCTAssertEqual(journal.view()?.phase, "running") + XCTAssertEqual(journal.base?.version, pause.id) + try TimerJournal().save() + } + + func testCountdownClampsAtZeroAfterSuspension() throws { + let value = snapshot() + let journal = TimerJournal(base: value) + let later = value.runningSinceMs! + 3_600_000 + XCTAssertEqual(journal.view(at: later)?.recordedMs, 60_000) + XCTAssertEqual(journal.view(at: later)?.phase, "finished") + XCTAssertNil(journal.view(at: later)?.runningSinceMs) + } + + func testLiveActivityIntentPausesResumesAndFinishesWithoutOpeningApp() async throws { + guard #available(iOS 17.0, *) else { return } + try TimerJournal(base: snapshot()).save() + try await TimerLiveActivity.refresh(allowStart: true) + XCTAssertEqual(Activity.activities.count, 1) + _ = try await ReadingTimerIntent(sessionId: "native-test", token: "v1", kind: "pause").perform() + var view = try XCTUnwrap(TimerJournal.load().view()) + XCTAssertEqual(view.phase, "paused") + _ = try await ReadingTimerIntent(sessionId: view.id, token: view.controlToken, kind: "resume").perform() + view = try XCTUnwrap(TimerJournal.load().view()) + XCTAssertEqual(view.phase, "running") + _ = try await ReadingTimerIntent(sessionId: view.id, token: view.controlToken, kind: "finish").perform() + XCTAssertNil(try TimerJournal.load().view()) + XCTAssertEqual(try TimerJournal.load().actions.count, 3) + XCTAssertTrue(Activity.activities.isEmpty) + try TimerJournal().save() + } } diff --git a/app/ios/TimerActivity/Config.xcconfig b/app/ios/TimerActivity/Config.xcconfig new file mode 100644 index 00000000..1cf56b24 --- /dev/null +++ b/app/ios/TimerActivity/Config.xcconfig @@ -0,0 +1 @@ +#include "../Flutter/Generated.xcconfig" diff --git a/app/ios/TimerActivity/Info.plist b/app/ios/TimerActivity/Info.plist new file mode 100644 index 00000000..ff79c79e --- /dev/null +++ b/app/ios/TimerActivity/Info.plist @@ -0,0 +1,6 @@ + + + +CFBundleDisplayNameReading timer +NSExtensionNSExtensionPointIdentifiercom.apple.widgetkit-extension + diff --git a/app/ios/TimerActivity/ReadingTimerWidget.swift b/app/ios/TimerActivity/ReadingTimerWidget.swift new file mode 100644 index 00000000..b0dad652 --- /dev/null +++ b/app/ios/TimerActivity/ReadingTimerWidget.swift @@ -0,0 +1,102 @@ +import ActivityKit +import SwiftUI +import WidgetKit + +@main +struct ReadingTimerWidget: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: ReadingTimerAttributes.self) { context in + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 12) { + Image(systemName: "book.closed").font(.title2) + VStack(alignment: .leading, spacing: 4) { + Text(context.state.title).font(.headline).lineLimit(2).privacySensitive() + Text(status(context)).font(.caption).foregroundStyle(.secondary) + } + Spacer(minLength: 8) + TimerClock(snapshot: context.state).font(.title2.monospacedDigit()) + } + TimerControls(snapshot: context.state, complete: context.isStale) + } + .padding(16) + .activitySystemActionForegroundColor(.primary) + .widgetURL(URL(string: "papyrus:///library/timer")) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Label("Reading", systemImage: "book.closed").font(.caption) + } + DynamicIslandExpandedRegion(.trailing) { + TimerClock(snapshot: context.state).monospacedDigit() + } + DynamicIslandExpandedRegion(.bottom) { + VStack(alignment: .leading, spacing: 8) { + Text(context.state.title).font(.headline).lineLimit(1).privacySensitive() + TimerControls(snapshot: context.state, complete: context.isStale) + } + } + } compactLeading: { + Image(systemName: context.state.phase == "paused" ? "pause.fill" : "book.closed") + } compactTrailing: { + TimerClock(snapshot: context.state).monospacedDigit().frame(maxWidth: 72) + } minimal: { + Image(systemName: context.state.phase == "paused" ? "pause.fill" : "timer") + } + .widgetURL(URL(string: "papyrus:///library/timer")) + } + } + + private func status(_ context: ActivityViewContext) -> String { + if context.isStale { return "Reading goal reached" } + if context.state.phase == "finished" { + return context.state.targetMs == context.state.recordedMs ? "Reading goal reached" : "Finished" + } + return context.state.phase == "paused" ? "Paused" : "Reading" + } +} + +private struct TimerClock: View { + let snapshot: TimerSnapshot + + var body: some View { + if let start = snapshot.runningSinceMs { + if let deadline = snapshot.deadline { + Text(timerInterval: Date(timeIntervalSince1970: 0)...deadline, countsDown: true) + .multilineTextAlignment(.trailing) + } else { + Text(Date(timeIntervalSince1970: Double(start - snapshot.recordedMs) / 1000), style: .timer) + .multilineTextAlignment(.trailing) + } + } else { + let seconds = max(0, (snapshot.targetMs.map { $0 - snapshot.recordedMs } ?? snapshot.recordedMs) / 1000) + Text(seconds >= 3600 ? String(format: "%lld:%02lld:%02lld", seconds / 3600, seconds / 60 % 60, seconds % 60) + : String(format: "%02lld:%02lld", seconds / 60, seconds % 60)) + } + } +} + +private struct TimerControls: View { + let snapshot: TimerSnapshot + let complete: Bool + + var body: some View { + if #available(iOS 17.0, *) { + HStack { + if !complete && snapshot.phase != "finished" { + let running = snapshot.phase == "running" + Button(intent: ReadingTimerIntent(sessionId: snapshot.id, token: snapshot.controlToken, kind: running ? "pause" : "resume")) { + Label(running ? "Pause" : "Resume", systemImage: running ? "pause.fill" : "play.fill") + .frame(maxWidth: .infinity) + } + } + Button(intent: ReadingTimerIntent(sessionId: snapshot.id, token: snapshot.controlToken, kind: "finish")) { + Label(complete || snapshot.phase == "finished" ? "Save" : "Finish", systemImage: "stop.fill") + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.bordered) + } else { + Link("Open timer", destination: URL(string: "papyrus:///library/timer")!) + } + } +} diff --git a/app/ios/TimerActivity/TimerActivityState.swift b/app/ios/TimerActivity/TimerActivityState.swift new file mode 100644 index 00000000..428951eb --- /dev/null +++ b/app/ios/TimerActivity/TimerActivityState.swift @@ -0,0 +1,154 @@ +import Foundation +import ActivityKit +import AppIntents + +struct TimerSnapshot: Codable, Hashable { + var id: String + var version: String + var title: String + var phase: String + var recordedMs: Int64 + var runningSinceMs: Int64? + var targetMs: Int64? + var token: String? + + var controlToken: String { token ?? version } + var deadline: Date? { + guard let start = runningSinceMs, let target = targetMs else { return nil } + return Date(timeIntervalSince1970: Double(start + target - recordedMs) / 1000) + } + + func elapsed(at: Int64) -> Int64 { + let total = recordedMs + (runningSinceMs.map { max(0, at - $0) } ?? 0) + return targetMs.map { min($0, total) } ?? total + } +} + +struct TimerNativeAction: Codable { + let id: String + let kind: String + let atMs: Int64 +} + +/// Only the app process writes this journal. LiveActivityIntent runs there too. +struct TimerJournal: Codable { + var base: TimerSnapshot? + var actions: [TimerNativeAction] = [] + + static var file: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("reading-timer-notification.json") + } + + static func load() throws -> TimerJournal { + guard FileManager.default.fileExists(atPath: file.path) else { return TimerJournal() } + return try JSONDecoder().decode(TimerJournal.self, from: Data(contentsOf: file)) + } + + func save() throws { + try FileManager.default.createDirectory(at: Self.file.deletingLastPathComponent(), withIntermediateDirectories: true) + try JSONEncoder().encode(self).write(to: Self.file, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + } + + func view(at: Int64 = Int64(Date().timeIntervalSince1970 * 1000)) -> TimerSnapshot? { + guard var value = base else { return nil } + for action in actions { + value.recordedMs = value.elapsed(at: action.atMs) + value.runningSinceMs = nil + value.token = action.id + switch action.kind { + case "pause": value.phase = "paused" + case "resume": value.phase = "running"; value.runningSinceMs = action.atMs + case "finish": return nil + default: break + } + } + if let target = value.targetMs, value.elapsed(at: at) >= target { + value.phase = "finished" + value.recordedMs = target + value.runningSinceMs = nil + } + return value + } + + mutating func record(sessionId: String, token: String, kind: String) throws { + guard let current = view(), current.id == sessionId, current.controlToken == token else { return } + guard ["pause", "resume", "finish"].contains(kind), actions.count < 256 else { return } + if kind == "pause" && current.phase != "running" { return } + if kind == "resume" && current.phase != "paused" { return } + actions.append(TimerNativeAction(id: UUID().uuidString, kind: kind, atMs: Int64(Date().timeIntervalSince1970 * 1000))) + try save() + } + + mutating func acknowledge(through: String, snapshot: TimerSnapshot?) throws { + guard let index = actions.firstIndex(where: { $0.id == through }) else { return } + actions = snapshot?.id == base?.id ? Array(actions.dropFirst(index + 1)) : [] + base = snapshot + try save() + } +} + +@available(iOS 16.2, *) +struct ReadingTimerAttributes: ActivityAttributes { + typealias ContentState = TimerSnapshot + var sessionId: String +} + +@available(iOS 16.2, *) +@MainActor +enum TimerLiveActivity { + private static var updating = false + private static var requested = false + private static var mayStart = false + + static func refresh(allowStart: Bool = false) async throws { + requested = true + mayStart = mayStart || allowStart + guard !updating else { return } + updating = true + defer { updating = false } + while requested { + requested = false + let start = mayStart + mayStart = false + let snapshot = try TimerJournal.load().view() + let activities = Activity.activities + for activity in activities where activity.attributes.sessionId != snapshot?.id { + await activity.end(nil, dismissalPolicy: .immediate) + } + guard let snapshot else { continue } + let content = ActivityContent(state: snapshot, staleDate: snapshot.deadline) + if let activity = activities.first(where: { $0.attributes.sessionId == snapshot.id }) { + await activity.update(content) + } else if start && snapshot.phase != "finished" && ActivityAuthorizationInfo().areActivitiesEnabled { + _ = try Activity.request(attributes: ReadingTimerAttributes(sessionId: snapshot.id), content: content, pushType: nil) + } + } + } +} + +@available(iOS 17.0, *) +struct ReadingTimerIntent: LiveActivityIntent { + static var title: LocalizedStringResource = "Control reading timer" + static var openAppWhenRun = false + + @Parameter(title: "Session") var sessionId: String + @Parameter(title: "Version") var token: String + @Parameter(title: "Action") var kind: String + + init() {} + init(sessionId: String, token: String, kind: String) { + self.sessionId = sessionId + self.token = token + self.kind = kind + } + + @MainActor + func perform() async throws -> some IntentResult { + var journal = try TimerJournal.load() + try journal.record(sessionId: sessionId, token: token, kind: kind) + try await TimerLiveActivity.refresh() + NotificationCenter.default.post(name: Notification.Name("PapyrusTimerChanged"), object: nil) + return .result() + } +} diff --git a/app/lib/config/app_router.dart b/app/lib/config/app_router.dart index 8611fda3..5faba8c3 100644 --- a/app/lib/config/app_router.dart +++ b/app/lib/config/app_router.dart @@ -1,3 +1,4 @@ +import 'package:papyrus/pages/reading_timer_page.dart'; import 'package:papyrus/pages/watched_folders_page.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -212,6 +213,11 @@ class AppRouter { path: 'notes', pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const NotesPage()), ), + GoRoute( + name: 'READING_TIMER', + path: 'timer', + pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const ReadingTimerPage()), + ), GoRoute( name: 'SEARCH_OPTIONS', path: 'search/options', diff --git a/app/lib/data/repositories/reading_timer_repository.dart b/app/lib/data/repositories/reading_timer_repository.dart new file mode 100644 index 00000000..43a99933 --- /dev/null +++ b/app/lib/data/repositories/reading_timer_repository.dart @@ -0,0 +1,11 @@ +import 'package:papyrus/timer/reading_timer.dart'; + +/// Captured per library so an in-flight operation cannot follow an account switch. +abstract interface class ReadingTimerRepository { + bool get isCurrent; + Future loadTimer(); + Stream watchTimer(); + Future writeTimer(ReadingTimer timer, {ReadingTimer? previous}); + Future discardTimer(ReadingTimer timer); + Future finishTimer(ReadingTimer timer, DateTime now, {int? currentPage}); +} diff --git a/app/lib/main.dart b/app/lib/main.dart index 16efa8ff..3f6e77e5 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -1,3 +1,5 @@ +import 'package:papyrus/services/reading_timer_notifications.dart'; +import 'package:papyrus/providers/reading_timer_provider.dart'; import 'package:papyrus/folders/directory_access_factory.dart'; import 'package:papyrus/folders/folder_import_target.dart'; import 'package:papyrus/folders/watched_folders.dart'; @@ -212,6 +214,8 @@ class AcquisitionDownloadsComposition { class _PapyrusState extends State { late final DataStore _dataStore; + late final ReadingTimerProvider _readingTimer; + late final ReadingTimerNotifications _timerNotifications; late final AuthProvider _authProvider; late final AcquisitionAvailabilityProvider _acquisitionAvailabilityProvider; late final AcquisitionDownloadsComposition _acquisitionDownloadsComposition; @@ -343,6 +347,12 @@ class _PapyrusState extends State { acquisitionAvailabilityProvider: _acquisitionAvailabilityProvider, ); + _readingTimer = ReadingTimerProvider(_dataStore); + _timerNotifications = ReadingTimerNotifications( + _readingTimer, + onOpen: () => _appRouter.router.go('/library/timer'), + ); + _authProvider.addListener(_syncPowerSyncAuthState); _syncSettingsProvider.addListener(_handleSyncSettingsChanged); _syncPowerSyncAuthState(); @@ -350,6 +360,8 @@ class _PapyrusState extends State { @override void dispose() { + _timerNotifications.dispose(); + _readingTimer.dispose(); _dataStore.removeListener(_handleFolderLibraryReady); _watchedFolders.dispose(); _authProvider.removeListener(_syncPowerSyncAuthState); @@ -621,6 +633,7 @@ class _PapyrusState extends State { providers: [ // Core data store - single source of truth ChangeNotifierProvider.value(value: _dataStore), + ChangeNotifierProvider.value(value: _readingTimer), ChangeNotifierProvider.value(value: _mediaUploadQueue), ChangeNotifierProvider.value(value: _watchedFolders), ChangeNotifierProvider.value(value: _syncSettingsProvider), diff --git a/app/lib/models/reading_activity.dart b/app/lib/models/reading_activity.dart index 926b726d..b303dcba 100644 --- a/app/lib/models/reading_activity.dart +++ b/app/lib/models/reading_activity.dart @@ -145,12 +145,15 @@ List effectiveActivities(Iterable activities) return unique.values.where((value) => value.kind != 'reversal' && !reversed.contains(value.id)).toList(); } -/// Checkpoints remain immutable records; presentation groups a reader opening. +/// Checkpoints remain immutable records; presentation groups a reading session. List groupReadingActivities(Iterable ledger) { final groups = >{}; for (final entry in ledger) { - final key = entry.source == 'reader' && entry.kind == 'reading' && entry.sessionId != null + final key = + (entry.source == 'reader' || entry.deviceType == 'physical_timer') && + entry.kind == 'reading' && + entry.sessionId != null ? '${entry.bookId}:${entry.sessionId}' : entry.id; @@ -168,11 +171,11 @@ List groupReadingActivities(Iterable ledger) { final last = entries.reduce((left, right) => left.endTime.isAfter(right.endTime) ? left : right); var cursor = first.startTime; var end = first.endTime; - var seconds = 0; + var microseconds = 0; for (final entry in entries.skip(1)) { if (entry.startTime.isAfter(end)) { - seconds += end.difference(cursor).inSeconds; + microseconds += end.difference(cursor).inMicroseconds; cursor = entry.startTime; } @@ -181,7 +184,7 @@ List groupReadingActivities(Iterable ledger) { } } - seconds += end.difference(cursor).inSeconds; + microseconds += end.difference(cursor).inMicroseconds; return ReadingActivity( id: first.id, @@ -197,7 +200,8 @@ List groupReadingActivities(Iterable ledger) { source: first.source, deviceId: first.deviceId, shelfIds: first.shelfIds, - recordedSeconds: seconds, + recordedSeconds: microseconds ~/ Duration.microsecondsPerSecond, + pages: entries.fold(0, (total, activity) => total + activity.pages), coverage: entries.expand((activity) => activity.coverage).toList(), constituentIds: entries.map((activity) => activity.id).toList(), ); diff --git a/app/lib/pages/reading_timer_page.dart b/app/lib/pages/reading_timer_page.dart new file mode 100644 index 00000000..6495187f --- /dev/null +++ b/app/lib/pages/reading_timer_page.dart @@ -0,0 +1,741 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:papyrus/providers/reading_timer_provider.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/library/library_drawer.dart'; +import 'package:papyrus/widgets/library/library_page_header.dart'; +import 'package:papyrus/widgets/shared/app_drawer.dart'; +import 'package:papyrus/widgets/shared/app_page_bar.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; +import 'package:papyrus/widgets/shared/searchable_book_field.dart'; +import 'package:papyrus/widgets/timer/reading_timer_clock.dart'; +import 'package:papyrus/widgets/timer/reading_timer_history.dart'; +import 'package:provider/provider.dart'; + +class ReadingTimerPage extends StatefulWidget { + const ReadingTimerPage({super.key}) : focusMode = false, initialBookId = null, initialTargetSeconds = null; + + const ReadingTimerPage._focus({required this.initialBookId, required this.initialTargetSeconds}) : focusMode = true; + + final bool focusMode; + final String? initialBookId; + final int? initialTargetSeconds; + + @override + State createState() => _ReadingTimerPageState(); +} + +class _ReadingTimerPageState extends State { + final _scaffold = GlobalKey(); + final _form = GlobalKey(); + final _minutes = TextEditingController(text: '30'); + String? _bookId; + String? _sessionId; + Object? _library; + bool _countdown = false; + bool _customDuration = false; + bool _saveDialogOpen = false; + bool _focusOpen = false; + + @override + void initState() { + super.initState(); + _library = context.read().libraryIdentity; + _bookId = widget.initialBookId; + _countdown = widget.initialTargetSeconds != null; + + if (widget.initialTargetSeconds != null) { + _minutes.text = '${widget.initialTargetSeconds! ~/ 60}'; + } + } + + @override + void dispose() { + _minutes.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final timer = context.watch(); + final books = context.watch().books; + final centered = context.select((preferences) => preferences.pageAlignment == 'center'); + + if (!identical(_library, timer.libraryIdentity)) { + _library = timer.libraryIdentity; + _bookId = null; + } + + if (_sessionId != timer.session?.id) { + _sessionId = timer.session?.id; + + if (timer.session != null) { + _bookId = timer.session!.bookId; + final minutes = timer.session!.targetSeconds; + + _countdown = minutes != null; + + if (minutes != null) { + _minutes.text = '${minutes ~/ 60}'; + _customDuration = ![15, 30, 60].contains(minutes ~/ 60); + } + } + } + + final book = books.where((book) => book.id == (timer.session?.bookId ?? _bookId)).firstOrNull; + final padding = libraryPageHorizontalPadding(context); + + if (widget.focusMode) { + return _focusView(context, timer, book); + } + + return Scaffold( + key: _scaffold, + drawer: const LibraryDrawer(currentPath: '/library/timer'), + body: SafeArea( + child: Column( + children: [ + LibraryCollectionHeader( + title: 'Timer', + onMenuPressed: () => openAppDrawer(context, _scaffold.currentState), + actions: [ + TextButton.icon( + onPressed: timer.available && !timer.busy && (book != null || timer.session != null) + ? () => _openFocus(timer, book) + : null, + icon: const Icon(Icons.fullscreen_rounded), + label: const Text('Focus'), + ), + ], + ), + Expanded( + child: LayoutBuilder( + builder: (context, viewport) { + return SingleChildScrollView( + padding: EdgeInsets.all(padding), + child: Form( + key: _form, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (timer.loading) const AppLinearProgressIndicator(), + if (timer.error != null && !_saveDialogOpen) + Padding( + padding: const EdgeInsets.only(bottom: Spacing.md), + child: Semantics( + liveRegion: true, + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: Spacing.sm, + children: [ + Text(timer.error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + TextButton(onPressed: timer.busy ? null : timer.reload, child: const Text('Retry')), + ], + ), + ), + ), + _session( + context, + timer, + books, + book, + centered: centered, + minimumHeight: (viewport.maxHeight - 2 * padding).clamp(0, double.infinity), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ), + ); + } + + Widget _session( + BuildContext context, + ReadingTimerProvider timer, + List books, + Book? book, { + required bool centered, + required double minimumHeight, + }) { + final theme = Theme.of(context); + final session = timer.session; + final countdown = session == null ? _countdown : session.targetSeconds != null; + final editable = session == null && timer.available && !timer.busy; + + final settings = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Mode', style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant)), + const SizedBox(height: Spacing.sm), + SegmentedButton( + key: ValueKey(MediaQuery.textScalerOf(context).scale(16) > 24), + expandedInsets: MediaQuery.textScalerOf(context).scale(16) > 24 ? null : EdgeInsets.zero, + direction: MediaQuery.textScalerOf(context).scale(16) > 24 ? Axis.vertical : Axis.horizontal, + style: ButtonStyle( + foregroundColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.disabled) ? theme.colorScheme.onSurfaceVariant : null, + ), + backgroundColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.disabled) && states.contains(WidgetState.selected) + ? theme.colorScheme.surfaceContainerHighest + : null, + ), + ), + segments: const [ + ButtonSegment(value: false, label: Text('Stopwatch'), icon: Icon(Icons.timer_outlined)), + ButtonSegment(value: true, label: Text('Countdown'), icon: Icon(Icons.hourglass_empty_rounded)), + ], + selected: {countdown}, + showSelectedIcon: false, + onSelectionChanged: editable ? (selection) => setState(() => _countdown = selection.single) : null, + ), + if (_countdown) + Visibility( + visible: countdown, + maintainSize: true, + maintainAnimation: true, + maintainState: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: Spacing.md), + Text( + 'Duration', + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + const SizedBox(height: Spacing.sm), + _duration(timer, editable && countdown), + ], + ), + ), + ], + ); + + final controls = Column( + key: const ValueKey('timer-controls-column'), + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SearchableBookField( + key: ValueKey((_library, book?.id)), + books: books, + value: book?.id, + enabled: editable, + showDetails: true, + onChanged: (id) => setState(() => _bookId = id), + ), + if (books.isEmpty && session == null) + Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: () => context.go('/library/books'), + child: const Text('Go to library'), + ), + ), + const SizedBox(height: Spacing.xl), + _instrument(context, timer, book), + const SizedBox(height: Spacing.lg), + settings, + ], + ); + + return LayoutBuilder( + key: const ValueKey('timer-panel-layout'), + builder: (context, constraints) { + final wide = constraints.maxWidth >= 960 && MediaQuery.textScalerOf(context).scale(16) <= 24; + + return ConstrainedBox( + key: const ValueKey('reading-session'), + constraints: BoxConstraints(minHeight: minimumHeight), + child: Align( + alignment: centered ? Alignment.topCenter : Alignment.topLeft, + child: ConstrainedBox( + key: const ValueKey('timer-content'), + constraints: BoxConstraints(maxWidth: wide ? 1040 : 520 - 2 * Spacing.lg), + child: wide + ? Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 520 - 2 * Spacing.lg, child: controls), + const SizedBox(width: Spacing.xxl), + const Expanded(child: ReadingTimerHistory()), + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + controls, + const SizedBox(height: Spacing.xl), + const Divider(height: 1), + const SizedBox(height: Spacing.lg), + const ReadingTimerHistory(), + ], + ), + ), + ), + ); + }, + ); + } + + Widget _instrument(BuildContext context, ReadingTimerProvider timer, Book? book) { + final session = timer.session; + final countdown = session == null ? _countdown : session.targetSeconds != null; + final target = session?.targetSeconds ?? (int.tryParse(_minutes.text) ?? 30).clamp(1, 1440) * 60; + final elapsed = timer.elapsed; + final remaining = Duration(seconds: (target - elapsed.inSeconds).clamp(0, target)); + + final status = switch (session) { + null => 'Ready', + _ when timer.goalReached => 'Goal reached', + _ when timer.finished => 'Finished', + _ when timer.running => 'Reading', + _ => 'Paused', + }; + + return Column( + children: [ + ConstrainedBox( + constraints: BoxConstraints(maxWidth: widget.focusMode ? 380 : 360), + child: ReadingTimerClock( + key: const ValueKey('timer-clock'), + digitSize: widget.focusMode ? 88 : 64, + time: countdown ? remaining : elapsed, + progress: countdown ? remaining.inSeconds / target : elapsed.inSeconds % 60 / 60, + countdown: countdown, + status: status, + ), + ), + const SizedBox(height: Spacing.xl), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: _controls(context, timer, book), + ), + ], + ); + } + + Future _openFocus(ReadingTimerProvider timer, Book? book) async { + if (_focusOpen || (timer.session == null && !_form.currentState!.validate())) { + return; + } + + _focusOpen = true; + + try { + await Navigator.of(context, rootNavigator: true).push( + _TimerFocusRoute( + builder: (context) => ReadingTimerPage._focus( + initialBookId: book?.id, + initialTargetSeconds: _countdown ? (int.tryParse(_minutes.text) ?? 30).clamp(1, 1440) * 60 : null, + ), + ), + ); + } finally { + _focusOpen = false; + } + } + + Widget _focusView(BuildContext context, ReadingTimerProvider timer, Book? book) { + final theme = Theme.of(context); + final actions = [ + TextButton.icon( + onPressed: () => Navigator.of(context).maybePop(), + icon: const Icon(Icons.fullscreen_exit_rounded), + label: const Text('Exit focus'), + ), + ]; + + final header = MediaQuery.sizeOf(context).width < Breakpoints.desktopSmall + ? AppPageToolbar(actions: actions) + : Padding( + padding: const EdgeInsets.only(top: Spacing.lg, left: Spacing.lg, right: Spacing.lg), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: kMinInteractiveDimension), + child: LibraryToolbar(actions: actions), + ), + ); + + return CallbackShortcuts( + bindings: {const SingleActivator(LogicalKeyboardKey.escape): () => Navigator.of(context).maybePop()}, + child: Focus( + autofocus: true, + child: Scaffold( + key: const ValueKey('timer-focus'), + body: SafeArea( + child: Column( + children: [ + header, + Expanded( + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: Center( + child: Padding( + padding: const EdgeInsets.all(Spacing.lg), + child: SizedBox( + width: 480, + child: Form( + key: _form, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + book?.title ?? timer.session?.bookTitle ?? 'No book selected', + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: Spacing.xl), + _instrument(context, timer, book), + if (timer.error != null && !_saveDialogOpen) ...[ + const SizedBox(height: Spacing.md), + Semantics( + liveRegion: true, + child: Text(timer.error!, style: TextStyle(color: theme.colorScheme.error)), + ), + TextButton( + onPressed: timer.busy ? null : timer.reload, + child: const Text('Retry'), + ), + ], + ], + ), + ), + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _duration(ReadingTimerProvider timer, bool editable) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + spacing: Spacing.sm, + children: [ + for (final minutes in [15, 30, 60]) + ChoiceChip( + showCheckmark: false, + labelPadding: const EdgeInsets.symmetric(horizontal: Spacing.xs), + label: Text('$minutes min'), + selected: !_customDuration && _minutes.text == '$minutes', + onSelected: !editable + ? null + : (_) => setState(() { + _minutes.text = '$minutes'; + _customDuration = false; + }), + ), + ChoiceChip( + showCheckmark: false, + labelPadding: const EdgeInsets.symmetric(horizontal: Spacing.xs), + label: const Text('Custom'), + selected: _customDuration, + onSelected: editable ? (_) => setState(() => _customDuration = true) : null, + ), + ], + ), + ), + if (_customDuration) ...[ + const SizedBox(height: Spacing.sm), + TextFormField( + controller: _minutes, + enabled: editable, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration(labelText: 'Minutes', suffixText: 'min'), + onChanged: (_) => setState(() {}), + validator: (value) { + if (!_countdown || timer.session != null) { + return null; + } + + final minutes = int.tryParse(value ?? ''); + return minutes == null || minutes < 1 || minutes > 1440 ? 'Enter 1 to 1,440 minutes.' : null; + }, + ), + ], + ], + ); + + Widget _controls(BuildContext context, ReadingTimerProvider timer, Book? book) { + final session = timer.session; + final enabled = timer.available; + final theme = Theme.of(context); + final shape = theme.outlinedButtonTheme.style?.shape?.resolve({}); + final buttonStyle = ButtonStyle( + visualDensity: VisualDensity.standard, + minimumSize: const WidgetStatePropertyAll(Size(0, 56)), + padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm)), + shape: WidgetStatePropertyAll(shape ?? RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.md))), + textStyle: WidgetStatePropertyAll(theme.textTheme.labelLarge), + ); + + final primaryLabel = switch (session) { + null => 'Start', + _ when timer.finished => 'Save', + _ when timer.running => 'Pause', + _ => 'Resume', + }; + + final primaryIcon = switch (session) { + _ when timer.finished => Icons.check_rounded, + _ when timer.running => Icons.pause_rounded, + _ => Icons.play_arrow_rounded, + }; + + final primary = FilledButton.icon( + key: const ValueKey('timer-primary-action'), + style: buttonStyle, + onPressed: !enabled || (session == null && book == null) + ? null + : () { + if (timer.busy) { + return; + } + + if (session == null) { + if (_form.currentState!.validate()) { + timer.start(book!, targetSeconds: _countdown ? int.parse(_minutes.text) * 60 : null); + } + } else if (timer.finished) { + _save(timer, book); + } else if (timer.running) { + timer.pause(); + } else { + timer.resume(); + } + }, + icon: Icon(primaryIcon), + label: Text(primaryLabel), + ); + + final reset = IconButton.outlined( + tooltip: 'Reset timer', + style: buttonStyle, + onPressed: enabled && session != null + ? () { + if (!timer.busy) { + _reset(timer); + } + } + : null, + icon: const Icon(Icons.restart_alt_rounded), + ); + + final secondary = IconButton.outlined( + key: const ValueKey('timer-secondary-action'), + tooltip: timer.finished ? 'Resume' : 'Finish', + style: buttonStyle, + onPressed: !enabled || session == null + ? null + : () async { + if (timer.busy) { + return; + } + + if (timer.finished) { + await timer.resume(); + } else if (await timer.pause(finish: true) && mounted) { + await _save(timer, book); + } + }, + icon: Icon(timer.finished ? Icons.play_arrow_rounded : Icons.stop_rounded), + ); + + if (MediaQuery.textScalerOf(context).scale(16) > 24) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + primary, + const SizedBox(height: Spacing.sm), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox(width: 56, height: 56, child: reset), + const SizedBox(width: Spacing.md), + SizedBox(width: 56, height: 56, child: secondary), + ], + ), + ], + ); + } + + return Row( + children: [ + SizedBox(width: 56, height: 56, child: reset), + const SizedBox(width: Spacing.sm), + Expanded(child: primary), + const SizedBox(width: Spacing.sm), + SizedBox(width: 56, height: 56, child: secondary), + ], + ); + } + + Future _reset(ReadingTimerProvider timer) async { + final session = timer.session; + final reset = await showDialog( + context: context, + animationStyle: AppMotion.animationStyle(context), + builder: (context) => AlertDialog( + title: const Text('Reset timer?'), + content: const Text('This sessionโ€™s reading time wonโ€™t be saved.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), + TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Reset')), + ], + ), + ); + + if (reset == true && mounted && timer.session?.id == session?.id) { + await timer.discard(); + } + } + + Future _save(ReadingTimerProvider timer, Book? book) async { + if (_saveDialogOpen) { + return; + } + + if (timer.elapsed.inSeconds < 1) { + await timer.discard(); + return; + } + + final sessionId = timer.session!.id; + setState(() => _saveDialogOpen = true); + + try { + final saved = await showDialog( + context: context, + animationStyle: AppMotion.animationStyle(context), + builder: (_) => _SaveSessionDialog(timer: timer, book: book, sessionId: sessionId), + ); + + if (saved == true && mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Reading session saved'))); + } + } finally { + if (mounted) { + setState(() => _saveDialogOpen = false); + } + } + } +} + +class _SaveSessionDialog extends StatefulWidget { + const _SaveSessionDialog({required this.timer, required this.book, required this.sessionId}); + + final ReadingTimerProvider timer; + final Book? book; + final String sessionId; + + @override + State<_SaveSessionDialog> createState() => _SaveSessionDialogState(); +} + +class _SaveSessionDialogState extends State<_SaveSessionDialog> { + final _page = TextEditingController(); + bool _saving = false; + + @override + void dispose() { + _page.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: widget.timer, + builder: (context, _) { + final timer = widget.timer; + final current = timer.session?.id == widget.sessionId; + final saving = _saving || timer.busy; + final error = timer.error ?? (!current && !_saving ? 'This session is no longer available.' : null); + + return AlertDialog( + title: const Text('Save session'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + controller: _page, + enabled: current && !saving && widget.book != null, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration(labelText: 'Current page (optional)'), + ), + if (error != null) ...[ + const SizedBox(height: Spacing.md), + Semantics( + liveRegion: true, + child: Text(error, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ), + ], + ], + ), + ), + actions: [ + TextButton(onPressed: saving ? null : () => Navigator.pop(context), child: const Text('Cancel')), + FilledButton( + onPressed: !current || saving + ? null + : () async { + setState(() => _saving = true); + final saved = await timer.save(currentPage: int.tryParse(_page.text)); + + if (!context.mounted) { + return; + } + + if (saved) { + Navigator.pop(context, true); + } else { + setState(() => _saving = false); + } + }, + child: const Text('Save'), + ), + ], + ); + }, + ); +} + +class _TimerFocusRoute extends MaterialPageRoute { + _TimerFocusRoute({required super.builder}) : super(settings: const RouteSettings(name: '/library/timer/focus')); + + @override + Duration get transitionDuration => Duration.zero; + + @override + Duration get reverseTransitionDuration => Duration.zero; +} diff --git a/app/lib/powersync/library_database.dart b/app/lib/powersync/library_database.dart index 095ccfcf..cdc6a9dc 100644 --- a/app/lib/powersync/library_database.dart +++ b/app/lib/powersync/library_database.dart @@ -1,4 +1,7 @@ import 'dart:convert'; +import 'package:papyrus/data/repositories/reading_timer_repository.dart'; +import 'package:papyrus/timer/reading_timer.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; import 'package:papyrus/powersync/tracking_schema_version.dart'; import 'package:papyrus/data/repositories/tracking_repository.dart'; import 'package:papyrus/models/reading_goal.dart'; @@ -13,7 +16,7 @@ import 'package:powersync/powersync.dart'; import 'package:sqlite_async/sqlite_async.dart'; /// A handle bound to one opened library, invalidated before a profile switch. -class LibraryDatabase implements LibraryMembershipWriter, TrackingRepository { +class LibraryDatabase implements LibraryMembershipWriter, TrackingRepository, ReadingTimerRepository { final PowerSyncDatabase database; final Future Function() onWrite; bool active = true; @@ -315,6 +318,160 @@ class LibraryDatabase implements LibraryMembershipWriter, TrackingRepository { }, previous: force && existing != null ? {'payload': null} : null); } + static const _activeTimerQuery = + "SELECT payload FROM reading_timers WHERE status = 'active' " + "ORDER BY json_extract(payload, '\$.started_at'), id LIMIT 1"; + + Future migrateTimer() => write((tx) async { + final legacy = await tx.getOptional('SELECT payload FROM reading_timer WHERE id = ?', ['active']); + + if (legacy == null) { + return; + } + + final timer = ReadingTimer.fromJson(jsonDecode(legacy['payload'] as String) as Map); + final existing = await tx.getOptional('SELECT id FROM reading_timers WHERE id = ?', [timer.id]); + + if (existing == null) { + await _writeTimer(tx, timer); + } + + await tx.execute('DELETE FROM reading_timer WHERE id = ?', ['active']); + }); + + @override + Future loadTimer() async { + checkActive(); + final row = await database.getOptional(_activeTimerQuery); + checkActive(); + return row == null ? null : ReadingTimer.fromJson(jsonDecode(row['payload'] as String) as Map); + } + + @override + Stream watchTimer() => database.watch(_activeTimerQuery).map((rows) { + checkActive(); + return rows.isEmpty + ? null + : ReadingTimer.fromJson(jsonDecode(rows.single['payload'] as String) as Map); + }); + + Future _checkTimer(SqliteWriteContext tx, ReadingTimer? expected) async { + final row = await tx.getOptional(_activeTimerQuery); + final current = row == null + ? null + : ReadingTimer.fromJson(jsonDecode(row['payload'] as String) as Map); + + if (current?.id != expected?.id || current?.version != expected?.version) { + throw StateError('The timer changed on another device or window. Reload it before continuing.'); + } + } + + Future _writeTimer( + SqliteWriteContext tx, + ReadingTimer timer, { + ReadingTimer? previous, + String status = 'active', + }) => upsertRow(tx, 'reading_timers', { + 'id': timer.id, + 'payload': jsonEncode(timer.toJson()), + 'status': status, + 'base_version': previous?.version, + 'created_at': timer.startedAt.toIso8601String(), + 'updated_at': DateTime.now().toUtc().toIso8601String(), + }); + + @override + Future writeTimer(ReadingTimer timer, {ReadingTimer? previous}) => write((tx) async { + await _checkTimer(tx, previous); + await _writeTimer(tx, timer, previous: previous); + }); + + @override + Future discardTimer(ReadingTimer timer) => write((tx) async { + await _checkTimer(tx, timer); + await _writeTimer(tx, timer.stop(DateTime.now().toUtc()), previous: timer, status: 'discarded'); + }); + + Future _timerActivity(SqliteWriteContext tx, Map payload) => + upsertRow(tx, 'reading_activities', { + 'id': payload['id'], + 'payload': jsonEncode(payload), + 'created_at': payload['created_at'], + 'updated_at': payload['created_at'], + }); + + @override + Future finishTimer(ReadingTimer timer, DateTime now, {int? currentPage}) => write((tx) async { + await _checkTimer(tx, timer); + final row = await tx.getOptional('SELECT * FROM books WHERE id = ?', [timer.bookId]); + + if (row == null) { + throw StateError('This book is no longer in the library. Discard this session to start another.'); + } + + final book = PowerSyncBookMapper.fromRow(Map.from(row)); + + if (currentPage != null && currentPage < 0) { + throw ArgumentError('Page must not be negative.'); + } + + final pages = currentPage != null && currentPage > timer.startPage ? currentPage - timer.startPage : 0; + final activities = timer.activities(now, pages: pages); + + if (activities.isEmpty || timer.elapsed(now).inSeconds < 1) { + throw StateError('Read for at least a second before saving a session.'); + } + + await _writeTimer(tx, timer.stop(now, finish: true), previous: timer, status: 'saved'); + + for (final activity in activities) { + await _timerActivity(tx, activity.toJson()); + } + + final end = activities.last.endTime; + final completed = currentPage != null && book.pageCount != null && currentPage == book.pageCount; + final position = currentPage != null && (book.pageCount ?? 0) > 0 + ? (currentPage / book.pageCount!).clamp(0.0, 1.0) + : null; + + var status = book.readingStatus; + + if (completed) { + status = LibraryReadingStatus.completed; + } else if (status == LibraryReadingStatus.unread) { + status = LibraryReadingStatus.inProgress; + } + + final updated = book.copyWith( + currentPage: currentPage, + currentPosition: position, + lastReadAt: book.lastReadAt != null && book.lastReadAt!.isAfter(end) ? book.lastReadAt : end, + startedAt: book.startedAt ?? timer.startedAt, + readingStatus: status, + completedAt: completed ? end : null, + ); + + if (completed && book.readingStatus != LibraryReadingStatus.completed) { + await _timerActivity( + tx, + ReadingActivity( + id: timer.id, + bookId: timer.bookId, + bookTitle: timer.bookTitle, + startTime: end, + endTime: end, + createdAt: now.isBefore(end) ? end : now, + kind: 'completion', + deviceId: timer.deviceId, + deviceType: 'physical_timer', + shelfIds: timer.shelfIds, + ).toJson(), + ); + } + + await upsertRow(tx, 'books', PowerSyncBookMapper.toRow(updated), previous: PowerSyncBookMapper.toRow(book)); + }); + @override Future commitTracking({ List goals = const [], diff --git a/app/lib/powersync/papyrus_powersync_connector.dart b/app/lib/powersync/papyrus_powersync_connector.dart index d4a19fb2..d2e07238 100644 --- a/app/lib/powersync/papyrus_powersync_connector.dart +++ b/app/lib/powersync/papyrus_powersync_connector.dart @@ -53,7 +53,8 @@ class PapyrusPowerSyncConnector extends PowerSyncBackendConnector { final version = supportsTracking?.call() == false ? 0 : trackingSchemaVersion?.call() ?? currentTrackingSchemaVersion; - final deferred = version == currentTrackingSchemaVersion + final timerTransaction = transaction.crud.any((entry) => entry.table == 'reading_timers'); + final deferred = version == currentTrackingSchemaVersion || timerTransaction ? [] : transaction.crud.where((entry) => trackingTableNames.contains(entry.table)).toList(); diff --git a/app/lib/powersync/papyrus_schema.dart b/app/lib/powersync/papyrus_schema.dart index 23f3591f..d41ba63f 100644 --- a/app/lib/powersync/papyrus_schema.dart +++ b/app/lib/powersync/papyrus_schema.dart @@ -137,11 +137,18 @@ const _stagingColumns = [ Column.text('payload'), Column.integer('deleted'), ]; +const _timerColumns = [ + ..._trackingColumns, + Column.text('status'), + Column.text('base_version'), +]; const papyrusAccountSchema = Schema([ + Table('reading_timers', _timerColumns), Table('reading_goals', _trackingColumns), Table('reading_activities', _trackingColumns), Table('goal_periods', _trackingColumns), Table.localOnly('tracking_staging', _stagingColumns), + Table.localOnly('reading_timer', [Column.text('payload')]), Table('books', _bookColumns, indexes: _bookIndexes), Table('shelves', _shelvesColumns), Table('tags', _tagsColumns), @@ -153,10 +160,12 @@ const papyrusAccountSchema = Schema([ ]); const papyrusGuestSchema = Schema([ + Table.localOnly('reading_timers', _timerColumns), Table.localOnly('reading_goals', _trackingColumns), Table.localOnly('reading_activities', _trackingColumns), Table.localOnly('goal_periods', _trackingColumns), Table.localOnly('tracking_staging', _stagingColumns), + Table.localOnly('reading_timer', [Column.text('payload')]), Table.localOnly('books', _bookColumns, indexes: _bookIndexes), Table.localOnly('shelves', _shelvesColumns), Table.localOnly('tags', _tagsColumns), diff --git a/app/lib/powersync/powersync_service.dart b/app/lib/powersync/powersync_service.dart index 0b15be97..d341d726 100644 --- a/app/lib/powersync/powersync_service.dart +++ b/app/lib/powersync/powersync_service.dart @@ -203,6 +203,9 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository, Trac await deleteFiles(snapshot.books); await database.writeTransaction((tx) async { + await tx.execute('DELETE FROM reading_timer'); + await tx.execute('DELETE FROM reading_timers'); + for (final table in libraryTableNames.reversed) { await tx.execute('DELETE FROM $table'); } @@ -334,6 +337,7 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository, Trac _database = database; _library = LibraryDatabase(database, _refreshPendingWrites); _library!.trackingSupported = mode == LibraryDatabaseMode.guest; + await _library!.migrateTimer(); _watchBooks(database); if (mode == LibraryDatabaseMode.authenticated && connectAuthenticated) { diff --git a/app/lib/providers/goals_provider.dart b/app/lib/providers/goals_provider.dart index 6bd15d59..81603e2a 100644 --- a/app/lib/providers/goals_provider.dart +++ b/app/lib/providers/goals_provider.dart @@ -391,7 +391,11 @@ class GoalsProvider extends ChangeNotifier { final shelfIds = correcting?.shelfIds ?? store.getShelfIdsForBook(book.id); final activities = [ - if (correcting != null) store.reversalFor(correcting, note: 'Corrected entry'), + if (correcting != null) + for (final entry in store.effectiveReadingActivities.where( + (activity) => activity.id == correcting.id || correcting.constituentIds.contains(activity.id), + )) + store.reversalFor(entry, note: 'Corrected entry'), ]; if (minutes > 0 || pages > 0) { diff --git a/app/lib/providers/reading_timer_provider.dart b/app/lib/providers/reading_timer_provider.dart new file mode 100644 index 00000000..864db214 --- /dev/null +++ b/app/lib/providers/reading_timer_provider.dart @@ -0,0 +1,276 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/data/repositories/reading_timer_repository.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/services/reading_device_identity.dart'; +import 'package:papyrus/timer/reading_timer.dart'; + +class ReadingTimerProvider extends ChangeNotifier { + ReadingTimerProvider(this.store, {DateTime Function()? now, bool tick = true}) : _now = now ?? DateTime.now { + store.addListener(_libraryChanged); + _libraryChanged(); + + if (tick) { + _clock = Timer.periodic(const Duration(seconds: 1), (_) { + _libraryChanged(); + + final seconds = session?.elapsed(this.now).inSeconds; + + if (seconds != _lastSecond) { + _lastSecond = seconds; + notifyListeners(); + } + }); + } + } + + final DataStore store; + final DateTime Function() _now; + Timer? _clock; + StreamSubscription? _subscription; + ReadingTimerRepository? _repository; + ReadingTimer? _session; + int _generation = 0; + int? _lastSecond; + bool _disposed = false; + bool loading = false; + bool busy = false; + String? error; + + DateTime get now => _now().toUtc(); + Object? get libraryIdentity => _repository; + ReadingTimer? get session => _repository?.isCurrent == true ? _session : null; + bool get available => _repository?.isCurrent == true && !loading; + Duration get elapsed => session?.elapsed(now) ?? Duration.zero; + bool get running => session?.isRunning(now) == true; + bool get goalReached => session?.reachedGoal(now) == true; + bool get finished => goalReached || session?.phase == ReadingTimerPhase.finished; + + ReadingTimerRepository? _currentRepository() { + try { + final repository = store.trackingRepository; + + if (repository is ReadingTimerRepository) { + final timerRepository = repository as ReadingTimerRepository; + return timerRepository.isCurrent ? timerRepository : null; + } + + return null; + } on StateError { + return null; + } + } + + void _libraryChanged() { + final repository = _currentRepository(); + + if (identical(repository, _repository)) { + return; + } + + unawaited(_subscription?.cancel()); + _subscription = null; + _repository = repository; + _session = null; + error = null; + busy = false; + loading = repository != null; + _generation++; + + if (repository != null) { + final generation = _generation; + _subscription = repository.watchTimer().listen( + (value) { + if (_isCurrent(generation, repository)) { + _session = value; + loading = false; + notifyListeners(); + } + }, + onError: (Object failure) { + if (_isCurrent(generation, repository)) { + loading = false; + error = 'Your timer could not be loaded. Try again.'; + notifyListeners(); + } + }, + ); + } else { + notifyListeners(); + } + } + + Future reload() async { + final repository = _repository; + final generation = _generation; + + if (repository == null) { + return; + } + + loading = true; + error = null; + notifyListeners(); + + try { + final restored = await repository.loadTimer(); + + if (_isCurrent(generation, repository)) { + _session = restored; + } + } catch (_) { + if (_isCurrent(generation, repository)) { + error = 'Your timer could not be loaded. Try again.'; + } + } finally { + if (_isCurrent(generation, repository)) { + loading = false; + notifyListeners(); + } + } + } + + bool _isCurrent(int generation, ReadingTimerRepository repository) => + !_disposed && generation == _generation && identical(repository, _repository) && repository.isCurrent; + + Future _change(Future Function(ReadingTimerRepository repository) action) async { + final repository = _repository; + final generation = _generation; + + if (repository == null || !available || busy) { + return false; + } + + busy = true; + error = null; + notifyListeners(); + + try { + await action(repository); + final next = await repository.loadTimer(); + + if (!_isCurrent(generation, repository)) { + return false; + } + + _session = next; + return true; + } catch (failure) { + if (_isCurrent(generation, repository)) { + error = failure is StateError || failure is ArgumentError + ? failure.toString().replaceFirst(RegExp(r'^(Bad state|Invalid argument\(s\)):\s*'), '') + : 'Your session could not be saved. Try again. Your timer is still available.'; + } + + return false; + } finally { + if (_isCurrent(generation, repository)) { + busy = false; + notifyListeners(); + } + } + } + + Future start(Book book, {int? targetSeconds}) => _change((repository) async { + if (session != null) { + throw StateError('Finish or discard the current session before starting another.'); + } + + final timer = ReadingTimer.start( + book, + now, + ReadingDeviceIdentity.current, + store.getShelfIdsForBook(book.id), + targetSeconds: targetSeconds, + ); + + await repository.writeTimer(timer); + return timer; + }); + + Future pause({bool finish = false}) => _change((repository) async { + final previous = session!; + final next = previous.stop(now, finish: finish); + await repository.writeTimer(next, previous: previous); + return next; + }); + + Future resume() => _change((repository) async { + final previous = session!; + final stopped = previous.stop(now); + final next = stopped.resume(now, keepReading: goalReached); + await repository.writeTimer(next, previous: previous); + return next; + }); + + Future save({int? currentPage}) => _change((repository) async { + await repository.finishTimer(session!, now, currentPage: currentPage); + return null; + }); + + Future discard() => _change((repository) async { + await repository.discardTimer(session!); + return null; + }); + + /// Replays native actions at their tap times, never at app-resume time. + /// Command IDs double as versions so a crash between writing and acknowledging + /// cannot apply the same interval twice. A changed remote version wins. + Future applyNotificationActions(Map batch) async { + final current = session; + final actions = (batch['actions'] as List).map((value) => Map.from(value as Map)).toList(); + final appliedIndex = actions.indexWhere((action) => action['id'] == current?.version); + + if (current == null || + current.id != batch['sessionId'] || + (current.version != batch['version'] && appliedIndex < 0)) { + return true; + } + + return _change((repository) async { + var next = current; + + for (var index = 0; index < actions.length; index++) { + final action = actions[index]; + final kind = action['kind'] as String; + final at = DateTime.fromMillisecondsSinceEpoch(action['atMs'] as int, isUtc: true); + + if (index > appliedIndex) { + final previous = next; + + next = switch (kind) { + 'pause' => next.stop(at), + 'resume' => next.stop(at).resume(at, keepReading: next.reachedGoal(at)), + 'finish' => next.stop(at, finish: true), + _ => throw ArgumentError('Unknown timer action.'), + }; + next = ReadingTimer.fromJson({...next.toJson(), 'version': action['id']}); + await repository.writeTimer(next, previous: previous); + } + + if (kind == 'finish' && index >= appliedIndex) { + if (next.elapsed(at).inSeconds < 1) { + await repository.discardTimer(next); + } else { + await repository.finishTimer(next, at); + } + + return null; + } + } + + return next; + }); + } + + @override + void dispose() { + _disposed = true; + _clock?.cancel(); + unawaited(_subscription?.cancel()); + store.removeListener(_libraryChanged); + super.dispose(); + } +} diff --git a/app/lib/services/reading_timer_notifications.dart b/app/lib/services/reading_timer_notifications.dart new file mode 100644 index 00000000..28902298 --- /dev/null +++ b/app/lib/services/reading_timer_notifications.dart @@ -0,0 +1,154 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:papyrus/providers/reading_timer_provider.dart'; + +/// Bridges native controls to the existing, profile-scoped timer repository. +/// Native actions are durably journaled before acknowledgement, including while +/// iOS suspends Dart. Their original timestamps are used when replaying them. +class ReadingTimerNotifications with WidgetsBindingObserver { + ReadingTimerNotifications(this.timer, {required this.onOpen, MethodChannel? channel, bool? supported}) + : _channel = channel ?? const MethodChannel('papyrus/reading_timer'), + _supported = + supported ?? (!kIsWeb && [TargetPlatform.android, TargetPlatform.iOS].contains(defaultTargetPlatform)) { + if (!_supported) { + return; + } + + _channel.setMethodCallHandler((call) async { + if (call.method == 'open') { + onOpen(); + } else if (call.method == 'changed') { + _lastPublished = null; + _schedule(); + } + }); + timer.addListener(_schedule); + WidgetsBinding.instance.addObserver(this); + _schedule(); + } + + final ReadingTimerProvider timer; + final VoidCallback onOpen; + final MethodChannel _channel; + final bool _supported; + bool _disposed = false; + bool _syncing = false; + bool _queued = false; + String? _lastPublished; + Object? _library; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _lastPublished = null; + _schedule(); + } + } + + void _schedule() { + if (_disposed) { + return; + } + + _queued = true; + + if (!_syncing) { + unawaited(_sync()); + } + } + + Future _sync() async { + _syncing = true; + + try { + while (_queued && !_disposed) { + _queued = false; + + if (_library != null && !identical(_library, timer.libraryIdentity)) { + await _channel.invokeMethod('clear'); + _lastPublished = null; + } + + _library = timer.libraryIdentity; + + if (!timer.available || timer.busy) { + continue; + } + + final pending = await _channel.invokeMapMethod('pending'); + + if (_disposed || !timer.available || timer.busy) { + continue; + } + + if (pending != null) { + final applied = await timer.applyNotificationActions(pending); + + if (!applied) { + // Retain the journal for a later retry if storage is unavailable. + break; + } + + await _channel.invokeMethod('acknowledge', { + 'through': pending['through'], + 'snapshot': snapshot(timer), + }); + _lastPublished = null; + _queued = true; + continue; + } + + final value = snapshot(timer); + final encoded = jsonEncode(value); + + if (encoded != _lastPublished) { + await _channel.invokeMethod('publish', { + 'snapshot': value, + 'foreground': WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed, + }); + _lastPublished = encoded; + } + } + } on MissingPluginException { + // Unsupported runtimes and older local builds keep the in-app timer. + } on PlatformException catch (error) { + debugPrint('Timer notification unavailable: ${error.code}'); + } finally { + _syncing = false; + } + } + + static Map? snapshot(ReadingTimerProvider timer) { + final session = timer.session; + + if (session == null) { + return null; + } + + final view = timer.goalReached ? session.stop(timer.now, finish: true) : session; + + return { + 'id': session.id, + 'version': session.version, + 'title': String.fromCharCodes(session.bookTitle.runes.take(200)), + 'phase': view.phase.name, + 'recordedMs': view.recorded.inMilliseconds, + 'runningSinceMs': view.runningSince?.millisecondsSinceEpoch, + 'targetMs': view.targetSeconds == null ? null : view.targetSeconds! * 1000, + }; + } + + void dispose() { + _disposed = true; + + if (_supported) { + timer.removeListener(_schedule); + WidgetsBinding.instance.removeObserver(this); + _channel.setMethodCallHandler(null); + } + } +} diff --git a/app/lib/timer/reading_timer.dart b/app/lib/timer/reading_timer.dart new file mode 100644 index 00000000..6aa0ab14 --- /dev/null +++ b/app/lib/timer/reading_timer.dart @@ -0,0 +1,201 @@ +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/models/reading_activity.dart'; +import 'package:uuid/uuid.dart'; + +enum ReadingTimerPhase { running, paused, finished } + +class ReadingInterval { + const ReadingInterval(this.start, this.end); + + final DateTime start; + final DateTime end; + Duration get duration => end.difference(start); + + Map toJson() => {'start': start.toIso8601String(), 'end': end.toIso8601String()}; + + factory ReadingInterval.fromJson(Map json) => + ReadingInterval(DateTime.parse(json['start'] as String), DateTime.parse(json['end'] as String)); +} + +/// Timestamp-based state shared across devices. Ticks are calculated locally. +class ReadingTimer { + const ReadingTimer({ + required this.id, + required this.bookId, + required this.bookTitle, + required this.startedAt, + required this.deviceId, + required this.shelfIds, + required this.startPage, + required this.phase, + this.targetSeconds, + this.runningSince, + this.intervals = const [], + this.revision = 0, + this.versionToken, + }); + + factory ReadingTimer.start(Book book, DateTime now, String deviceId, List shelfIds, {int? targetSeconds}) { + if (targetSeconds != null && (targetSeconds < 60 || targetSeconds > 86400)) { + throw ArgumentError('Choose a duration from 1 minute to 24 hours.'); + } + + return ReadingTimer( + id: const Uuid().v4(), + bookId: book.id, + bookTitle: book.title, + startedAt: now.toUtc(), + runningSince: now.toUtc(), + deviceId: deviceId, + shelfIds: List.unmodifiable(shelfIds), + startPage: book.currentPage ?? 0, + targetSeconds: targetSeconds, + phase: ReadingTimerPhase.running, + ); + } + + final String id; + final String bookId; + final String bookTitle; + final DateTime startedAt; + final String deviceId; + final List shelfIds; + final int startPage; + final int? targetSeconds; + final ReadingTimerPhase phase; + final DateTime? runningSince; + final List intervals; + final int revision; + final String? versionToken; + String get version => versionToken ?? '$id:$revision'; + + Duration get recorded => intervals.fold(Duration.zero, (total, interval) => total + interval.duration); + + DateTime? intervalEnd(DateTime now) { + final start = runningSince; + + if (start == null) { + return null; + } + + var end = now.toUtc().isBefore(start) ? start : now.toUtc(); + final target = targetSeconds; + + if (target != null) { + final deadline = start.add(Duration(seconds: target) - recorded); + + if (end.isAfter(deadline)) { + end = deadline; + } + } + + return end; + } + + Duration elapsed(DateTime now) { + final end = intervalEnd(now); + return recorded + (end == null ? Duration.zero : end.difference(runningSince!)); + } + + bool reachedGoal(DateTime now) => targetSeconds != null && elapsed(now).inSeconds >= targetSeconds!; + bool isRunning(DateTime now) => phase == ReadingTimerPhase.running && !reachedGoal(now); + + ReadingTimer stop(DateTime now, {bool finish = false}) { + final end = intervalEnd(now); + + return _copy( + phase: finish || reachedGoal(now) ? ReadingTimerPhase.finished : ReadingTimerPhase.paused, + intervals: [ + ...intervals, + if (end != null && end.isAfter(runningSince!)) ReadingInterval(runningSince!, end), + ], + ); + } + + ReadingTimer resume(DateTime now, {bool keepReading = false}) { + final earliest = intervals.isEmpty ? startedAt : intervals.last.end; + final requested = now.toUtc(); + + return _copy( + phase: ReadingTimerPhase.running, + runningSince: requested.isBefore(earliest) ? earliest : requested, + clearTarget: keepReading, + ); + } + + ReadingTimer _copy({ + required ReadingTimerPhase phase, + DateTime? runningSince, + List? intervals, + bool clearTarget = false, + }) => ReadingTimer( + id: id, + bookId: bookId, + bookTitle: bookTitle, + startedAt: startedAt, + deviceId: deviceId, + shelfIds: shelfIds, + startPage: startPage, + targetSeconds: clearTarget ? null : targetSeconds, + phase: phase, + runningSince: runningSince, + intervals: intervals ?? this.intervals, + revision: revision + 1, + versionToken: const Uuid().v4(), + ); + + List activities(DateTime now, {int pages = 0}) { + final stopped = stop(now, finish: true); + + return [ + for (final (index, interval) in stopped.intervals.indexed) + ReadingActivity( + id: const Uuid().v5(id, '$index'), + sessionId: id, + bookId: bookId, + bookTitle: bookTitle, + startTime: interval.start, + endTime: interval.end, + createdAt: now.isBefore(interval.end) ? interval.end : now.toUtc(), + deviceId: deviceId, + deviceType: 'physical_timer', + shelfIds: shelfIds, + pages: index == stopped.intervals.length - 1 ? pages : 0, + ), + ]; + } + + Map toJson() => { + 'id': id, + 'book_id': bookId, + 'book_title': bookTitle, + 'started_at': startedAt.toIso8601String(), + 'device_id': deviceId, + 'shelf_ids': shelfIds, + 'start_page': startPage, + 'target_seconds': targetSeconds, + 'phase': phase.name, + 'running_since': runningSince?.toIso8601String(), + 'intervals': intervals.map((interval) => interval.toJson()).toList(), + 'revision': revision, + 'version': version, + }; + + factory ReadingTimer.fromJson(Map json) => ReadingTimer( + id: json['id'] as String, + bookId: json['book_id'] as String, + bookTitle: json['book_title'] as String, + startedAt: DateTime.parse(json['started_at'] as String), + deviceId: json['device_id'] as String, + shelfIds: List.from(json['shelf_ids'] as List), + startPage: json['start_page'] as int, + targetSeconds: json['target_seconds'] as int?, + phase: ReadingTimerPhase.values.byName(json['phase'] as String), + runningSince: json['running_since'] == null ? null : DateTime.parse(json['running_since'] as String), + intervals: (json['intervals'] as List) + .map((value) => ReadingInterval.fromJson(Map.from(value as Map))) + .toList(), + revision: json['revision'] as int, + versionToken: json['version'] as String?, + ); +} diff --git a/app/lib/timer/reading_timer_format.dart b/app/lib/timer/reading_timer_format.dart new file mode 100644 index 00000000..38bf9991 --- /dev/null +++ b/app/lib/timer/reading_timer_format.dart @@ -0,0 +1,6 @@ +String readingTimerClock(Duration duration) { + final seconds = duration.inSeconds.clamp(0, 99999999); + final minutes = (seconds ~/ 60 % 60).toString().padLeft(2, '0'); + final remainder = (seconds % 60).toString().padLeft(2, '0'); + return seconds >= 3600 ? '${seconds ~/ 3600}:$minutes:$remainder' : '$minutes:$remainder'; +} diff --git a/app/lib/widgets/library/library_drawer.dart b/app/lib/widgets/library/library_drawer.dart index 882fba42..3d639b1d 100644 --- a/app/lib/widgets/library/library_drawer.dart +++ b/app/lib/widgets/library/library_drawer.dart @@ -110,6 +110,15 @@ class LibraryDrawer extends StatelessWidget { context.go('/library/notes'); }, ), + _DrawerNavItem( + icon: Icons.timer_outlined, + label: 'Timer', + isSelected: currentPath == '/library/timer', + onTap: () { + Navigator.of(context).pop(); + context.go('/library/timer'); + }, + ), ], ), ), diff --git a/app/lib/widgets/shared/app_page_bar.dart b/app/lib/widgets/shared/app_page_bar.dart index 44a0003a..174531a2 100644 --- a/app/lib/widgets/shared/app_page_bar.dart +++ b/app/lib/widgets/shared/app_page_bar.dart @@ -1,14 +1,16 @@ import 'package:flutter/material.dart'; +import 'package:papyrus/themes/app_motion.dart'; import 'package:papyrus/themes/design_tokens.dart'; /// Mobile toolbar geometry shared by scaffold app bars and inline page headers. /// The containing page or scaffold owns the system safe-area inset. class AppPageToolbar extends StatelessWidget { - const AppPageToolbar({super.key, this.leading, this.content, this.actions = const []}); + const AppPageToolbar({super.key, this.leading, this.content, this.actions = const [], this.scrollAware = true}); final Widget? leading; final Widget? content; final List actions; + final bool scrollAware; static const padding = EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm); @@ -25,34 +27,106 @@ class AppPageToolbar extends StatelessWidget { } @override - Widget build(BuildContext context) => Padding( - padding: padding, - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: height(context) - padding.vertical), - child: IconTheme.merge( - data: const IconThemeData(size: IconSizes.navigation), - child: Row( - children: [ - if (leading != null) ...[ - SizedBox.square(dimension: TouchTargets.mobileRecommended, child: leading), - const SizedBox(width: Spacing.sm), - ], - Expanded(child: content ?? const SizedBox.shrink()), - for (final action in actions) ...[ - const SizedBox(width: Spacing.sm), - ConstrainedBox( - constraints: const BoxConstraints( - minWidth: TouchTargets.mobileRecommended, - minHeight: TouchTargets.mobileRecommended, + Widget build(BuildContext context) { + final toolbar = Padding( + padding: padding, + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: height(context) - padding.vertical), + child: IconTheme.merge( + data: const IconThemeData(size: IconSizes.navigation), + child: Row( + children: [ + if (leading != null) ...[ + SizedBox.square(dimension: TouchTargets.mobileRecommended, child: leading), + const SizedBox(width: Spacing.sm), + ], + Expanded(child: content ?? const SizedBox.shrink()), + for (final action in actions) ...[ + const SizedBox(width: Spacing.sm), + ConstrainedBox( + constraints: const BoxConstraints( + minWidth: TouchTargets.mobileRecommended, + minHeight: TouchTargets.mobileRecommended, + ), + child: action, ), - child: action, - ), + ], ], - ], + ), ), ), - ), - ); + ); + + return scrollAware ? _ScrolledHeader(child: toolbar) : toolbar; + } +} + +/// Inline headers share the Scaffold's scroll observer with native app bars. +class _ScrolledHeader extends StatefulWidget { + const _ScrolledHeader({required this.child}); + + final Widget child; + + @override + State<_ScrolledHeader> createState() => _ScrolledHeaderState(); +} + +class _ScrolledHeaderState extends State<_ScrolledHeader> { + ScrollNotificationObserverState? _observer; + bool _scrolledUnder = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _observer?.removeListener(_onScroll); + _observer = ScrollNotificationObserver.maybeOf(context); + _observer?.addListener(_onScroll); + } + + @override + void dispose() { + _observer?.removeListener(_onScroll); + super.dispose(); + } + + void _onScroll(ScrollNotification notification) { + if (notification is! ScrollUpdateNotification || !defaultScrollNotificationPredicate(notification)) { + return; + } + + final metrics = notification.metrics; + final scrolledUnder = switch (metrics.axisDirection) { + AxisDirection.down => metrics.extentBefore > 0, + AxisDirection.up => metrics.extentAfter > 0, + AxisDirection.left || AxisDirection.right => _scrolledUnder, + }; + + if (scrolledUnder != _scrolledUnder) { + setState(() => _scrolledUnder = scrolledUnder); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final bar = theme.appBarTheme; + final states = {if (_scrolledUnder) WidgetState.scrolledUnder}; + final background = + bar.backgroundColor ?? + (_scrolledUnder && theme.useMaterial3 ? theme.colorScheme.surfaceContainer : theme.colorScheme.surface); + final elevation = bar.elevation ?? (theme.useMaterial3 ? 0 : 4); + + return Material( + color: WidgetStateProperty.resolveAs(background, states), + elevation: _scrolledUnder ? bar.scrolledUnderElevation ?? (theme.useMaterial3 ? 3 : elevation) : elevation, + shadowColor: bar.shadowColor ?? (theme.useMaterial3 ? Colors.transparent : theme.shadowColor), + surfaceTintColor: bar.surfaceTintColor ?? (theme.useMaterial3 ? theme.colorScheme.surfaceTint : null), + shape: bar.shape, + animationDuration: AppMotion.duration(context, kThemeChangeDuration), + textStyle: DefaultTextStyle.of(context).style, + child: widget.child, + ); + } } /// Scaffold app bar using the same mobile toolbar as inline page headers. @@ -112,7 +186,9 @@ class AppPageBar extends StatelessWidget implements PreferredSizeWidget { : null, centerTitle: compact ? false : null, titleSpacing: compact ? 0 : null, - title: compact ? AppPageToolbar(leading: navigation, content: title, actions: actions) : title, + title: compact + ? AppPageToolbar(leading: navigation, content: title, actions: actions, scrollAware: false) + : title, actions: compact ? null : actions, ); } diff --git a/app/lib/widgets/shared/searchable_book_field.dart b/app/lib/widgets/shared/searchable_book_field.dart index 56dcd1d5..d72e02bb 100644 --- a/app/lib/widgets/shared/searchable_book_field.dart +++ b/app/lib/widgets/shared/searchable_book_field.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/themes/app_motion.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/book/private_book_cover.dart'; import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; /// A validated library selection, with title, author and ISBN search. @@ -11,12 +12,21 @@ class SearchableBookField extends FormField { required List books, String? value, super.enabled = true, + bool showDetails = false, required ValueChanged onChanged, }) : super( initialValue: value, validator: (value) => books.any((book) => book.id == value) ? null : 'Choose a book.', builder: (field) { final selected = books.where((book) => book.id == field.value).firstOrNull; + final theme = Theme.of(field.context); + final shape = theme.cardTheme.shape; + final cardBorder = OutlineInputBorder( + borderRadius: shape is RoundedRectangleBorder + ? shape.borderRadius.resolve(Directionality.of(field.context)) + : BorderRadius.circular(AppRadius.lg), + borderSide: BorderSide(color: theme.colorScheme.outlineVariant), + ); return InkWell( onTap: !enabled || books.isEmpty @@ -38,19 +48,103 @@ class SearchableBookField extends FormField { } }, child: InputDecorator( - isEmpty: selected == null, + isEmpty: selected == null && !showDetails, decoration: InputDecoration( - labelText: 'Book', + labelText: showDetails ? null : 'Book', + filled: showDetails ? false : null, + contentPadding: showDetails ? const EdgeInsets.all(Spacing.md) : null, + border: showDetails ? cardBorder : null, + enabledBorder: showDetails ? cardBorder : null, errorText: field.errorText, - suffixIcon: const Icon(Icons.search), + suffixIcon: showDetails + ? const Padding( + padding: EdgeInsetsDirectional.only(end: Spacing.md), + child: Icon(Icons.expand_more_rounded), + ) + : const Icon(Icons.search), ), - child: Text(selected?.title ?? '', maxLines: 2, overflow: TextOverflow.ellipsis), + child: showDetails + ? _BookDetails(book: selected) + : Text(selected?.title ?? '', maxLines: 2, overflow: TextOverflow.ellipsis), ), ); }, ); } +class _BookDetails extends StatelessWidget { + const _BookDetails({this.book}); + + final Book? book; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final secondary = theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant); + + final cover = Container( + width: 48, + height: 64, + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + clipBehavior: Clip.antiAlias, + child: CoverImage( + bookId: book?.id, + imageUrl: book?.coverUrl, + mediaId: book?.coverMediaId, + placeholder: Icon(Icons.menu_book_rounded, color: theme.colorScheme.onPrimaryContainer), + ), + ); + + final details = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + book == null ? 'Book' : 'Currently reading', + style: secondary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: Spacing.xs), + Text( + book?.title ?? 'Choose a book', + style: theme.textTheme.titleMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: Spacing.xs), + Text( + book?.allAuthors.isNotEmpty == true ? book!.allAuthors : ' ', + style: secondary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ); + + if (MediaQuery.textScalerOf(context).scale(16) > 24) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + cover, + const SizedBox(height: Spacing.sm), + details, + ], + ); + } + + return Row( + children: [ + cover, + const SizedBox(width: Spacing.md), + Expanded(child: details), + ], + ); + } +} + class _BookSearchSheet extends StatefulWidget { const _BookSearchSheet({required this.books, this.selectedId}); final List books; diff --git a/app/lib/widgets/shell/adaptive_app_shell.dart b/app/lib/widgets/shell/adaptive_app_shell.dart index 6c360f50..70dd487e 100644 --- a/app/lib/widgets/shell/adaptive_app_shell.dart +++ b/app/lib/widgets/shell/adaptive_app_shell.dart @@ -99,6 +99,12 @@ class AdaptiveAppShell extends StatelessWidget { count: dataStore.notes.length, selectedIcon: Icons.notes, ), + const AppShellNavItem( + path: '/library/timer', + label: 'Timer', + icon: Icons.timer_outlined, + selectedIcon: Icons.timer, + ), ], ), const AppShellNavItem( diff --git a/app/lib/widgets/timer/reading_timer_clock.dart b/app/lib/widgets/timer/reading_timer_clock.dart new file mode 100644 index 00000000..6096128c --- /dev/null +++ b/app/lib/widgets/timer/reading_timer_clock.dart @@ -0,0 +1,145 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/timer/reading_timer_format.dart'; + +/// A determinate reading clock: remaining fraction, or seconds within a minute. +class ReadingTimerClock extends StatelessWidget { + const ReadingTimerClock({ + super.key, + required this.time, + required this.progress, + required this.countdown, + required this.status, + this.digitSize = 64, + }); + + final Duration time; + final double progress; + final bool countdown; + final String status; + final double digitSize; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final clock = readingTimerClock(time); + + final largeText = MediaQuery.textScalerOf(context).scale(16) > 22; + final label = Text( + countdown ? 'Time remaining' : 'Time elapsed', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ); + + final digitStyle = theme.textTheme.displayLarge?.copyWith( + fontSize: digitSize, + fontWeight: FontWeight.w300, + fontFeatures: const [FontFeature.tabularFigures()], + ); + + final digits = SizedBox( + width: double.infinity, + height: MediaQuery.textScalerOf(context).scale(digitSize) * (digitStyle?.height ?? 1), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text(clock, style: digitStyle), + ), + ); + + final state = Container( + padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.xs), + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + side: BorderSide(color: theme.colorScheme.outlineVariant), + ), + ), + child: Text(status, textAlign: TextAlign.center, style: theme.textTheme.labelLarge), + ); + + final ring = AspectRatio( + aspectRatio: 1, + child: CustomPaint( + painter: _ClockRing( + progress: progress, + color: theme.colorScheme.primary, + track: Color.alphaBlend(theme.colorScheme.onSurface.withValues(alpha: 0.18), theme.colorScheme.surface), + ), + child: Padding( + padding: const EdgeInsets.all(Spacing.xl), + child: largeText + ? Center(child: digits) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + label, + const SizedBox(height: Spacing.sm), + Flexible(child: digits), + const SizedBox(height: Spacing.sm), + state, + ], + ), + ), + ), + ); + + return Semantics( + label: '${countdown ? 'Time remaining' : 'Time elapsed'}: $clock. $status', + child: ExcludeSemantics( + child: largeText + ? Column( + children: [ + label, + const SizedBox(height: Spacing.sm), + ring, + const SizedBox(height: Spacing.sm), + state, + ], + ) + : ring, + ), + ); + } +} + +class _ClockRing extends CustomPainter { + const _ClockRing({required this.progress, required this.color, required this.track}); + + final double progress; + final Color color; + final Color track; + static const double _strokeWidth = 28; + + @override + void paint(Canvas canvas, Size size) { + final center = size.center(Offset.zero); + final radius = size.shortestSide / 2 - _strokeWidth / 2 - 2; + + final paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = _strokeWidth + ..strokeCap = StrokeCap.round + ..color = track; + + canvas.drawCircle(center, radius, paint); + + if (progress > 0) { + paint.color = color; + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + -math.pi / 2, + math.pi * 2 * progress, + false, + paint, + ); + } else { + canvas.drawCircle(Offset(center.dx, center.dy - radius), _strokeWidth / 2, Paint()..color = color); + } + } + + @override + bool shouldRepaint(_ClockRing oldDelegate) => + progress != oldDelegate.progress || color != oldDelegate.color || track != oldDelegate.track; +} diff --git a/app/lib/widgets/timer/reading_timer_history.dart b/app/lib/widgets/timer/reading_timer_history.dart new file mode 100644 index 00000000..a85a2cbf --- /dev/null +++ b/app/lib/widgets/timer/reading_timer_history.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/models/reading_activity.dart'; +import 'package:papyrus/models/reading_goal.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/book/private_book_cover.dart'; +import 'package:provider/provider.dart'; + +class ReadingTimerHistory extends StatelessWidget { + const ReadingTimerHistory({super.key}); + + @override + Widget build(BuildContext context) { + final store = context.watch(); + final theme = Theme.of(context); + + final sessions = + groupReadingActivities( + store.effectiveReadingActivities, + ).where((activity) => activity.kind == 'reading' && activity.seconds > 0).toList() + ..sort((left, right) => right.endTime.compareTo(left.endTime)); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Semantics( + header: true, + child: Text('Recent sessions', style: theme.textTheme.titleMedium), + ), + const SizedBox(height: Spacing.md), + if (sessions.isEmpty) + Text( + 'No saved sessions yet.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + for (final session in sessions.take(8)) ...[ + if (session != sessions.first) const Divider(height: Spacing.lg), + _session(context, store, session), + ], + ], + ); + } + + Widget _session(BuildContext context, DataStore store, ReadingActivity session) { + final theme = Theme.of(context); + final book = store.getBook(session.bookId); + final title = book?.title ?? session.bookTitle; + final date = DateFormat.yMMMd().add_jm().format(session.endTime.toLocal()); + final duration = session.seconds < 60 ? '${session.seconds}s' : formatDuration(session.seconds ~/ 60); + + return Row( + key: ValueKey('recent-session-${session.id}'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 36, + height: 54, + child: ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.sm), + child: CoverImage( + bookId: book?.id, + imageUrl: book?.coverUrl, + mediaId: book?.coverMediaId, + placeholder: ColoredBox( + color: theme.colorScheme.primaryContainer, + child: Icon(Icons.menu_book_outlined, color: theme.colorScheme.onPrimaryContainer, size: 20), + ), + ), + ), + ), + const SizedBox(width: Spacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Tooltip( + message: title, + child: Text(title, maxLines: 2, overflow: TextOverflow.ellipsis, style: theme.textTheme.titleSmall), + ), + const SizedBox(height: Spacing.xs), + Text(date, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant)), + ], + ), + ), + const SizedBox(width: Spacing.sm), + Text(duration, style: theme.textTheme.labelLarge), + ], + ); + } +} diff --git a/app/test/goals/goals_provider_test.dart b/app/test/goals/goals_provider_test.dart index a8171ee4..5bb721a7 100644 --- a/app/test/goals/goals_provider_test.dart +++ b/app/test/goals/goals_provider_test.dart @@ -1,3 +1,5 @@ +import 'package:papyrus/models/reading_activity.dart'; +import 'package:papyrus/timer/reading_timer.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:papyrus/services/reading_device_identity.dart'; @@ -35,6 +37,25 @@ void main() { expect(ReadingDeviceIdentity.current, 'installation-one'); }); + test('correcting a timer session reverses all paused intervals', () async { + final start = DateTime.utc(2026, 11, 3, 12); + final now = start.add(const Duration(hours: 1)); + final book = Book(id: 'book', title: 'Book', author: '', addedAt: start, isPhysical: true); + final store = DataStore()..loadData(books: [book]); + final provider = GoalsProvider(now: () => now, watchClock: false)..attach(store); + addTearDown(provider.dispose); + addTearDown(store.dispose); + var timer = ReadingTimer.start(book, start, 'device', []); + timer = timer.stop(start.add(const Duration(minutes: 10))); + timer = timer.resume(start.add(const Duration(minutes: 20))); + await store.commitTracking(activities: timer.activities(start.add(const Duration(minutes: 30)))); + final grouped = groupReadingActivities(store.effectiveReadingActivities).single; + await provider.logReading(book: book, end: now, minutes: 15, correcting: grouped); + expect(store.effectiveReadingActivities.length, 1); + expect(store.effectiveReadingActivities.single.seconds, 900); + expect(store.readingActivities.where((activity) => activity.kind == 'reversal').length, 2); + }); + test('correcting duplicate completions only clears status after the last confirmation', () async { var now = DateTime.utc(2026, 10, 5, 12); final book = Book(id: 'book', title: 'Book', author: '', addedAt: now); diff --git a/app/test/pages/reading_timer_page_test.dart b/app/test/pages/reading_timer_page_test.dart new file mode 100644 index 00000000..cf7d3c70 --- /dev/null +++ b/app/test/pages/reading_timer_page_test.dart @@ -0,0 +1,684 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/pages/reading_timer_page.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:papyrus/providers/reading_timer_provider.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/widgets/shared/app_page_bar.dart'; +import 'package:papyrus/widgets/shared/searchable_book_field.dart'; +import 'package:papyrus/widgets/timer/reading_timer_clock.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../timer/timer_test_library.dart'; + +void main() { + setUpAll(() async { + final flutterRoot = Platform.environment['FLUTTER_ROOT']!; + final font = File('$flutterRoot/bin/cache/artifacts/material_fonts/Roboto-Regular.ttf'); + await (FontLoader('Roboto')..addFont(Future.value(ByteData.sublistView(font.readAsBytesSync())))).load(); + final icons = File('$flutterRoot/bin/cache/artifacts/material_fonts/MaterialIcons-Regular.otf'); + await (FontLoader('MaterialIcons')..addFont(Future.value(ByteData.sublistView(icons.readAsBytesSync())))).load(); + }); + + final start = DateTime.utc(2026, 11, 3, 12); + final book = Book(id: 'digital', title: 'A Wizard of Earthsea', author: 'Ursula K. Le Guin', addedAt: start); + late TimerTestLibrary library; + late DataStore store; + late ReadingTimerProvider timer; + late PreferencesProvider preferences; + late DateTime now; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + preferences = PreferencesProvider(await SharedPreferences.getInstance()); + library = TimerTestLibrary()..replaceAll([book]); + store = DataStore(bookRepository: library); + now = start; + timer = ReadingTimerProvider(store, now: () => now, tick: false); + await timer.reload(); + }); + + tearDown(() { + timer.dispose(); + store.dispose(); + preferences.dispose(); + library.trackingRepository.changes.close(); + }); + + Future pump( + WidgetTester tester, { + Size size = const Size(1200, 900), + double scale = 1, + ThemeData? theme, + }) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.reset); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: store), + ChangeNotifierProvider.value(value: timer), + ChangeNotifierProvider.value(value: preferences), + ], + child: MaterialApp( + theme: theme ?? AppTheme.dark, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + home: const ReadingTimerPage(), + ), + ), + ); + await tester.pumpAndSettle(); + } + + Future tap(WidgetTester tester, String label) async { + final text = find.text(label); + final target = text.evaluate().isEmpty ? find.byTooltip(label).last : text.last; + await tester.ensureVisible(target); + await tester.tap(target); + await tester.pumpAndSettle(); + } + + Future selectBook(WidgetTester tester) async { + await tester.tap(find.byType(SearchableBookField)); + await tester.pumpAndSettle(); + expect(find.text('Search books'), findsOneWidget); + await tap(tester, book.title); + } + + testWidgets('focus preserves setup and the same running timer when opened and closed', (tester) async { + await pump(tester); + expect(tester.widget(find.widgetWithText(TextButton, 'Focus')).onPressed, isNull); + await selectBook(tester); + await tap(tester, 'Countdown'); + await tap(tester, '15 min'); + await tap(tester, 'Focus'); + expect(find.byKey(const ValueKey('timer-focus')), findsOneWidget); + expect(find.byType(SearchableBookField), findsNothing); + expect(find.text('Mode'), findsNothing); + expect(find.text('Duration'), findsNothing); + expect(timer.session, isNull); + await tap(tester, 'Start'); + final session = timer.session!; + expect(session.bookId, book.id); + expect(session.targetSeconds, 900); + now = now.add(const Duration(seconds: 45)); + await tap(tester, 'Exit focus'); + expect(timer.session, same(session)); + expect(timer.elapsed, const Duration(seconds: 45)); + expect(find.byKey(const ValueKey('timer-focus')), findsNothing); + await tap(tester, 'Focus'); + expect(timer.session, same(session)); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('timer-focus')), findsNothing); + expect(timer.running, isTrue); + }); + + testWidgets('iOS edge swipe exits focus without stopping the timer', (tester) async { + await pump( + tester, + size: const Size(390, 844), + theme: AppTheme.dark.copyWith(platform: TargetPlatform.iOS), + ); + await selectBook(tester); + await tap(tester, 'Start'); + final session = timer.session!; + await tap(tester, 'Focus'); + await tester.dragFrom(const Offset(1, 400), const Offset(340, 0)); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('timer-focus')), findsNothing); + expect(timer.session, same(session)); + expect(timer.running, isTrue); + }); + + testWidgets('focus opens and closes without transition animations', (tester) async { + await pump(tester); + await selectBook(tester); + await tap(tester, 'Focus'); + final route = ModalRoute.of(tester.element(find.byKey(const ValueKey('timer-focus'))))!; + expect(route.transitionDuration, Duration.zero); + expect(route.reverseTransitionDuration, Duration.zero); + }); + + for (final size in [const Size(1200, 900), const Size(390, 844)]) { + testWidgets('focus toggle keeps its header position at width ${size.width}', (tester) async { + await pump(tester, size: size); + await selectBook(tester); + final enter = tester.getRect(find.widgetWithText(TextButton, 'Focus')); + await tap(tester, 'Focus'); + final exit = tester.getRect(find.widgetWithText(TextButton, 'Exit focus')); + expect(exit.right, enter.right); + expect(exit.center.dy, enter.center.dy); + expect(exit.height, enter.height); + }); + } + + testWidgets('mobile timer header changes surface when scrolled and resets at the top', (tester) async { + final fonts = AppTheme.dark.textTheme.apply(fontFamily: 'Roboto'); + await pump( + tester, + size: const Size(390, 600), + theme: AppTheme.dark.copyWith( + textTheme: fonts, + textButtonTheme: TextButtonThemeData( + style: AppTheme.dark.textButtonTheme.style?.copyWith(textStyle: WidgetStatePropertyAll(fonts.labelLarge)), + ), + ), + ); + + final surface = find.descendant(of: find.byType(AppPageToolbar), matching: find.byType(Material)).first; + final initial = tester.widget(surface).elevation; + final header = tester.getRect(find.byType(AppPageToolbar)); + final scroll = tester.state(find.byType(Scrollable).first).position; + scroll.jumpTo(scroll.maxScrollExtent); + await tester.pumpAndSettle(); + expect(tester.widget(surface).elevation, greaterThan(initial)); + expect(tester.getRect(find.byType(AppPageToolbar)), header); + + scroll.jumpTo(0); + await tester.pumpAndSettle(); + expect(tester.widget(surface).elevation, initial); + }); + + testWidgets('focus actions pause resume and save through the existing session flow', (tester) async { + await pump(tester); + await selectBook(tester); + await tap(tester, 'Start'); + now = now.add(const Duration(minutes: 2)); + await tap(tester, 'Focus'); + await tap(tester, 'Pause'); + expect(timer.running, isFalse); + await tap(tester, 'Resume'); + now = now.add(const Duration(minutes: 1)); + await tap(tester, 'Finish'); + await tester.enterText(find.byType(TextFormField), '12'); + await tap(tester, 'Save'); + expect(library.trackingRepository.savedSeconds, 180); + expect(library.trackingRepository.savedPage, 12); + expect(find.byKey(const ValueKey('timer-focus')), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('timer-focus')), findsNothing); + expect(timer.session, isNull); + }); + + testWidgets('remote state changes update focus without toggling its local presentation', (tester) async { + await pump(tester); + await selectBook(tester); + await tap(tester, 'Focus'); + await timer.start(book, targetSeconds: 60); + await tester.pumpAndSettle(); + expect(find.text('Time remaining'), findsOneWidget); + now = now.add(const Duration(minutes: 2)); + await timer.reload(); + await tester.pumpAndSettle(); + expect(find.text('Goal reached'), findsOneWidget); + await timer.discard(); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('timer-focus')), findsOneWidget); + expect(find.text('Ready'), findsOneWidget); + }); + + for (final (name, size, scale, theme) in [ + ('desktop-dark', const Size(1440, 900), 1.0, AppTheme.dark), + ('phone-dark', const Size(390, 844), 1.0, AppTheme.dark), + ('phone-light', const Size(390, 844), 1.0, AppTheme.light), + ('phone-eink-large', const Size(320, 700), 2.0, AppTheme.eink), + ('phone-landscape', const Size(844, 390), 1.0, AppTheme.dark), + ]) { + testWidgets('focus $name keeps actions reachable', (tester) async { + final fonts = theme.textTheme.apply(fontFamily: 'Roboto'); + + await pump( + tester, + size: size, + scale: scale, + theme: theme.copyWith( + textTheme: fonts, + textButtonTheme: TextButtonThemeData( + style: theme.textButtonTheme.style?.copyWith(textStyle: WidgetStatePropertyAll(fonts.labelLarge)), + ), + ), + ); + + await selectBook(tester); + await tap(tester, 'Countdown'); + await tap(tester, 'Start'); + now = now.add(const Duration(minutes: 5)); + await tap(tester, 'Focus'); + expect(find.text('25:00'), findsOneWidget); + expect(tester.takeException(), isNull); + + await tap(tester, 'Pause'); + expect(timer.running, isFalse); + + await tap(tester, 'Exit focus'); + expect(find.byKey(const ValueKey('timer-focus')), findsNothing); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('empty library keeps clock visible and start disabled', (tester) async { + store.replaceBooksFromSync([]); + await pump(tester); + expect(find.byType(ReadingTimerClock), findsOneWidget); + expect(tester.widget(find.widgetWithText(FilledButton, 'Start')).onPressed, isNull); + expect(find.text('Go to library'), findsOneWidget); + }); + + testWidgets('selects an ebook, pauses, resumes and saves optional page progress', (tester) async { + await pump(tester); + await selectBook(tester); + await tap(tester, 'Start'); + now = start.add(const Duration(minutes: 2, seconds: 15)); + await tap(tester, 'Pause'); + expect(timer.elapsed, const Duration(minutes: 2, seconds: 15)); + expect(tester.widget(find.byType(ReadingTimerClock)).progress, 0.25); + now = now.add(const Duration(minutes: 5)); + await tap(tester, 'Resume'); + now = now.add(const Duration(minutes: 1)); + await tap(tester, 'Finish'); + expect(find.text('Save session'), findsOneWidget); + expect(library.trackingRepository.saves, 0); + await tester.enterText(find.byType(TextFormField), '12'); + await tap(tester, 'Save'); + expect(timer.session, isNull); + expect(find.byType(AlertDialog), findsNothing); + expect(library.trackingRepository.savedSeconds, 195); + expect(library.trackingRepository.savedPage, 12); + expect(library.trackingRepository.saves, 1); + expect(find.text('Reading session saved'), findsOneWidget); + }); + + testWidgets('countdown drains, caps at zero and can continue without counting the gap', (tester) async { + await pump(tester); + await selectBook(tester); + await tap(tester, 'Countdown'); + await tap(tester, 'Custom'); + await tester.enterText(find.byType(TextFormField), '1'); + await tap(tester, 'Start'); + now = now.add(const Duration(seconds: 30)); + await timer.reload(); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(ReadingTimerClock)).progress, 0.5); + now = now.add(const Duration(minutes: 10)); + await timer.reload(); + await tester.pumpAndSettle(); + expect(find.text('Goal reached'), findsOneWidget); + expect(tester.widget(find.byType(ReadingTimerClock)).time, Duration.zero); + await tap(tester, 'Resume'); + now = now.add(const Duration(seconds: 30)); + await tap(tester, 'Finish'); + await tap(tester, 'Save'); + expect(library.trackingRepository.savedSeconds, 90); + }); + + for (final countdown in [false, true]) { + testWidgets('immediately finishing ${countdown ? 'countdown' : 'stopwatch'} clears it without a prompt', ( + tester, + ) async { + await pump(tester); + await selectBook(tester); + + if (countdown) { + await tap(tester, 'Countdown'); + } + + await tap(tester, 'Start'); + now = now.add(const Duration(milliseconds: 500)); + await tap(tester, 'Finish'); + expect(timer.session, isNull); + expect(timer.error, isNull); + expect(find.byType(AlertDialog), findsNothing); + expect(find.text('Start'), findsOneWidget); + expect(library.trackingRepository.saves, 0); + expect(find.text('Reading session saved'), findsNothing); + }); + } + + testWidgets('failed save appears only in the dialog and keeps entered progress for retry', (tester) async { + await timer.start(book); + now = now.add(const Duration(minutes: 1)); + await timer.pause(finish: true); + await pump(tester); + await tap(tester, 'Save'); + await tester.enterText(find.byType(TextFormField), '9'); + library.trackingRepository.failWrite = true; + await tap(tester, 'Save'); + expect(find.text('Save failed. Try again.', skipOffstage: false), findsOneWidget); + expect(find.byType(AlertDialog), findsOneWidget); + expect(timer.session, isNotNull); + library.trackingRepository.failWrite = false; + await tap(tester, 'Save'); + expect(timer.session, isNull); + expect(library.trackingRepository.saves, 1); + expect(library.trackingRepository.savedSeconds, 60); + expect(library.trackingRepository.savedPage, 9); + }); + + for (final page in [null, 5, 199]) { + testWidgets('one-second session offers optional page input $page without range checks', (tester) async { + final selected = book.copyWith(pageCount: 98, currentPage: 50); + store.replaceBooksFromSync([selected]); + await timer.start(selected); + now = now.add(const Duration(seconds: 1)); + await pump(tester); + await tap(tester, 'Finish'); + expect(find.text('Save session'), findsOneWidget); + + if (page != null) { + await tester.enterText(find.byType(TextFormField), '$page'); + } + + await tester.tap(find.widgetWithText(FilledButton, 'Save').last); + + for (var frame = 0; frame < 25; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + expect(find.text('This session is no longer available.', skipOffstage: false), findsNothing); + } + + expect(timer.session, isNull); + expect(library.trackingRepository.savedPage, page); + expect(library.trackingRepository.savedSeconds, 1); + expect(find.text('Reading session saved'), findsOneWidget); + }); + } + + testWidgets('invalid custom duration stays editable and cannot start', (tester) async { + await pump(tester); + await selectBook(tester); + await tap(tester, 'Countdown'); + await tap(tester, 'Custom'); + await tester.enterText(find.byType(TextFormField), ''); + await tap(tester, 'Custom'); + await tap(tester, 'Start'); + expect(find.text('Enter 1 to 1,440 minutes.'), findsOneWidget); + expect(timer.session, isNull); + await tap(tester, '15 min'); + await tap(tester, 'Start'); + expect(timer.session!.targetSeconds, 900); + }); + + testWidgets('reset requires confirmation and keeps the selected book and duration', (tester) async { + await pump(tester); + await selectBook(tester); + await tap(tester, 'Countdown'); + await tap(tester, '15 min'); + await tap(tester, 'Start'); + now = now.add(const Duration(minutes: 1)); + await tap(tester, 'Reset timer'); + await tap(tester, 'Cancel'); + expect(timer.session, isNotNull); + await tap(tester, 'Reset timer'); + await tap(tester, 'Reset'); + expect(timer.session, isNull); + expect(library.trackingRepository.saves, 0); + expect(find.text(book.title), findsOneWidget); + expect(tester.widget(find.byType(ReadingTimerClock)).time, const Duration(minutes: 15)); + await tap(tester, 'Start'); + expect(timer.session!.bookId, book.id); + expect(timer.session!.targetSeconds, 900); + }); + + testWidgets('duration appears only for countdown and retains the chosen duration', (tester) async { + final theme = AppTheme.dark; + + await pump( + tester, + size: const Size(390, 1000), + theme: theme.copyWith( + chipTheme: theme.chipTheme.copyWith(labelStyle: theme.textTheme.labelLarge!.copyWith(fontFamily: 'Roboto')), + ), + ); + await selectBook(tester); + final clock = tester.getRect(find.byType(ReadingTimerClock)); + expect(find.text('Duration'), findsNothing); + expect(find.byType(ChoiceChip), findsNothing); + expect(find.byIcon(Icons.expand_more_rounded), findsOneWidget); + expect(tester.widget>(find.byType(SegmentedButton)).showSelectedIcon, isFalse); + await tap(tester, 'Countdown'); + expect(find.text('Duration'), findsOneWidget); + final chips = tester.getRect(find.byType(ChoiceChip).first); + final custom = tester.getRect(find.widgetWithText(ChoiceChip, 'Custom')); + final panel = tester.getRect(find.byKey(const ValueKey('reading-session'))); + expect(custom.top, chips.top); + expect(custom.right, lessThanOrEqualTo(panel.right - 24)); + expect(tester.getRect(find.byType(ReadingTimerClock)), clock); + expect(tester.widget(find.widgetWithText(ChoiceChip, '30 min')).onSelected, isNotNull); + expect(tester.widgetList(find.byType(ChoiceChip)).every((chip) => chip.showCheckmark == false), isTrue); + await tap(tester, '60 min'); + await tap(tester, 'Stopwatch'); + expect(find.text('Duration'), findsNothing); + expect(tester.getRect(find.byType(ReadingTimerClock)), clock); + await tap(tester, 'Countdown'); + expect(tester.widget(find.byType(ReadingTimerClock)).time, const Duration(hours: 1)); + }); + + for (final countdown in [false, true]) { + testWidgets('${countdown ? 'countdown' : 'stopwatch'} anchors survive all session states', (tester) async { + await pump(tester); + await selectBook(tester); + + if (countdown) { + await tap(tester, 'Countdown'); + await tap(tester, 'Custom'); + await tester.enterText(find.byType(TextFormField), '2'); + } + + Rect panelRect(Finder finder) => + tester.getRect(finder).shift(-tester.getTopLeft(find.byKey(const ValueKey('reading-session')))); + + final clock = panelRect(find.byType(ReadingTimerClock)); + final bookField = panelRect(find.byType(SearchableBookField)); + final primary = panelRect(find.byKey(const ValueKey('timer-primary-action'))); + + void expectAnchors() { + expect(panelRect(find.byType(ReadingTimerClock)), clock); + expect(panelRect(find.byType(SearchableBookField)), bookField); + expect(panelRect(find.byKey(const ValueKey('timer-primary-action'))), primary); + } + + await tap(tester, 'Start'); + expectAnchors(); + now = now.add(const Duration(seconds: 10)); + await tap(tester, 'Pause'); + expectAnchors(); + await tap(tester, 'Resume'); + expectAnchors(); + + if (countdown) { + now = now.add(const Duration(minutes: 3)); + await timer.reload(); + await tester.pumpAndSettle(); + expectAnchors(); + await tap(tester, 'Resume'); + expectAnchors(); + } + + await tap(tester, 'Finish'); + await tap(tester, 'Cancel'); + expectAnchors(); + expect(find.textContaining('read.'), findsNothing); + expect(find.text('Discard session'), findsNothing); + expect(bookField.bottom, lessThan(clock.top)); + expect(clock.bottom, lessThan(primary.top)); + expect(primary.bottom, lessThan(panelRect(find.text('Mode')).top)); + }); + } + + testWidgets('changing text scale keeps mode choices visible', (tester) async { + await pump(tester, size: const Size(390, 1000)); + await selectBook(tester); + await tap(tester, 'Start'); + await pump(tester, size: const Size(390, 1000), scale: 2, theme: AppTheme.eink); + final modes = tester.getRect(find.byType(SegmentedButton)); + expect(modes.height, greaterThan(80)); + expect(modes.contains(tester.getCenter(find.text('Stopwatch'))), isTrue); + expect(modes.contains(tester.getCenter(find.text('Countdown'))), isTrue); + expect(tester.takeException(), isNull); + }); + + testWidgets('timer button ink survives pending writes and state changes', (tester) async { + final ink = _TrackingInkFactory(); + await pump(tester, theme: AppTheme.dark.copyWith(splashFactory: ink)); + await selectBook(tester); + + for (final label in ['Start', 'Pause', 'Resume', 'Finish']) { + final gate = Completer(); + library.trackingRepository.writeGate = gate; + final target = label == 'Finish' ? find.byTooltip(label) : find.text(label); + final previousCount = ink.features.length; + await tester.tap(target); + final ripple = ink.features.last; + final color = ripple.color; + await tester.pump(const Duration(milliseconds: 16)); + expect(timer.busy, isTrue); + expect(ink.features.length, previousCount + 1); + expect(ink.removed.contains(ripple), isFalse); + expect(ripple.color, color); + final primary = tester.widget(find.byKey(const ValueKey('timer-primary-action'))); + expect(primary.onPressed, isNotNull); + primary.onPressed!(); + gate.complete(); + await tester.pump(const Duration(milliseconds: 16)); + expect(timer.busy, isFalse); + expect(ink.features.length, previousCount + 1); + expect(ink.removed.contains(ripple), isFalse); + expect(ripple.color, color); + await tester.pumpAndSettle(); + now = now.add(const Duration(minutes: 1)); + } + + expect(find.byType(AlertDialog), findsOneWidget); + expect(library.trackingRepository.saves, 0); + }); + + for (final (size, scale) in [ + (const Size(1200, 900), 1.0), + (const Size(390, 1000), 1.0), + (const Size(320, 1000), 2.0), + ]) { + testWidgets('book selection keeps its height at ${size.width} and text scale $scale', (tester) async { + final longBook = Book( + id: 'long', + title: 'A very long book title that takes several lines in a narrow selection field', + author: 'An author with a very long name and several additional contributors', + addedAt: start, + ); + final untitledAuthor = Book(id: 'short', title: 'Earthsea', author: '', addedAt: start); + store.replaceBooksFromSync([book, longBook, untitledAuthor]); + await pump(tester, size: size, scale: scale); + final field = find.byType(SearchableBookField); + final height = tester.getSize(field).height; + + for (final selected in [book, longBook, untitledAuthor]) { + await tester.ensureVisible(field); + await tester.tap(field); + await tester.pumpAndSettle(); + await tap(tester, selected.title); + expect(tester.getSize(field).height, height); + expect(tester.takeException(), isNull); + } + }); + } + + testWidgets('content fills page height and follows the alignment preference without extra padding', (tester) async { + await pump(tester, size: const Size(1600, 1200)); + final panel = find.byKey(const ValueKey('reading-session')); + final field = find.byType(SearchableBookField); + final bounds = tester.getRect(panel); + final controls = tester.getRect(field); + expect(bounds.left, 24); + expect(bounds.right, 1576); + expect(bounds.bottom, 1176); + expect(controls.left, bounds.left); + expect(controls.width, 472); + preferences.pageAlignment = 'center'; + await tester.pumpAndSettle(); + expect(tester.getRect(panel), bounds); + expect(tester.getSize(field).width, controls.width); + expect(tester.getCenter(find.byKey(const ValueKey('timer-content'))).dx, bounds.center.dx); + expect(tester.getTopLeft(find.text('Recent sessions')).dx, greaterThan(tester.getTopRight(field).dx)); + }); + + testWidgets('narrow layout places history after timer settings and scrolls to its bottom', (tester) async { + await pump(tester, size: const Size(390, 700)); + await tap(tester, 'Countdown'); + expect( + tester.getTopLeft(find.text('Recent sessions')).dy, + greaterThan(tester.getBottomLeft(find.text('Custom')).dy), + ); + await tester.ensureVisible(find.text('No saved sessions yet.')); + await tester.pumpAndSettle(); + expect(find.text('No saved sessions yet.').hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + for (final (name, size, scale, theme) in [ + ('phone', const Size(390, 844), 1.0, AppTheme.light), + ('large text e-ink', const Size(320, 700), 2.0, AppTheme.eink), + ]) { + testWidgets('$name keeps controls reachable without overflow', (tester) async { + await pump(tester, size: size, scale: scale, theme: theme); + await selectBook(tester); + await tap(tester, 'Countdown'); + await tap(tester, 'Start'); + now = now.add(const Duration(minutes: 1)); + await tap(tester, 'Finish'); + await tap(tester, 'Save'); + expect(library.trackingRepository.saves, 1); + expect(tester.takeException(), isNull); + }); + } +} + +class _TrackingInkFactory extends InteractiveInkFeatureFactory { + final features = []; + final removed = []; + + @override + InteractiveInkFeature create({ + required MaterialInkController controller, + required RenderBox referenceBox, + required Offset position, + required Color color, + required TextDirection textDirection, + bool containedInkWell = false, + RectCallback? rectCallback, + BorderRadius? borderRadius, + ShapeBorder? customBorder, + double? radius, + VoidCallback? onRemoved, + }) { + late InteractiveInkFeature feature; + feature = InkRipple.splashFactory.create( + controller: controller, + referenceBox: referenceBox, + position: position, + color: color, + textDirection: textDirection, + containedInkWell: containedInkWell, + rectCallback: rectCallback, + borderRadius: borderRadius, + customBorder: customBorder, + radius: radius, + onRemoved: () { + removed.add(feature); + onRemoved?.call(); + }, + ); + features.add(feature); + return feature; + } +} diff --git a/app/test/powersync/reading_timer_persistence_test.dart b/app/test/powersync/reading_timer_persistence_test.dart new file mode 100644 index 00000000..924464b8 --- /dev/null +++ b/app/test/powersync/reading_timer_persistence_test.dart @@ -0,0 +1,278 @@ +import 'dart:io'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/models/reading_activity.dart'; +import 'package:papyrus/powersync/library_database.dart'; +import 'package:papyrus/auth/papyrus_api_config.dart'; +import 'package:papyrus/powersync/papyrus_powersync_connector.dart'; +import 'tracking_persistence_test.dart' show CapturingUploadRepository; +import 'package:papyrus/powersync/papyrus_schema.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; +import 'package:papyrus/timer/reading_timer.dart'; +import 'package:powersync/powersync.dart'; + +void main() { + final start = DateTime.utc(2026, 11, 3, 23, 50); + final book = Book( + id: 'physical', + title: 'Physical book', + author: 'Author', + addedAt: start, + isPhysical: true, + currentPage: 10, + pageCount: 100, + ); + + late Directory directory; + late PowerSyncDatabase database; + late LibraryDatabase library; + + Future open({bool account = false}) async { + database = PowerSyncDatabase( + path: '${directory.path}/timer.db', + schema: account ? papyrusAccountSchema : papyrusGuestSchema, + ); + await database.initialize(); + library = LibraryDatabase(database, () async {})..trackingSupported = !account; + } + + setUp(() async { + directory = await Directory.systemTemp.createTemp('papyrus-timer-'); + await open(); + await library.books.upsert(book); + }); + + tearDown(() async { + await database.close(); + await directory.delete(recursive: true); + }); + + test('running timer survives closing SQLite and caps a suspended countdown', () async { + final timer = ReadingTimer.start(book, start, 'device', [], targetSeconds: 1200); + await library.writeTimer(timer); + await database.close(); + await open(); + final restored = (await library.loadTimer())!; + final later = start.add(const Duration(hours: 3)); + expect(restored.elapsed(later), const Duration(minutes: 20)); + expect(restored.isRunning(later), isFalse); + await library.finishTimer(restored, later, currentPage: 25); + expect(await library.loadTimer(), isNull); + final snapshot = await library.snapshot(); + expect(snapshot.activities.single.seconds, 1200); + expect(snapshot.activities.single.pages, 15); + expect(snapshot.activities.single.endTime, start.add(const Duration(minutes: 20))); + expect(snapshot.books.single.currentPage, 25); + expect(snapshot.books.single.readingStatus, LibraryReadingStatus.inProgress); + await expectLater(library.finishTimer(restored, later, currentPage: 25), throwsStateError); + expect((await library.snapshot()).activities.length, 1); + }); + + test('pauses preserve real intervals across midnight and group into one session', () async { + var timer = ReadingTimer.start(book, start, 'device', []); + await library.writeTimer(timer); + var next = timer.stop(start.add(const Duration(minutes: 5))); + await library.writeTimer(next, previous: timer); + timer = next; + next = timer.resume(start.add(const Duration(minutes: 20))); + await library.writeTimer(next, previous: timer); + timer = next; + final end = start.add(const Duration(minutes: 30)); + await library.finishTimer(timer, end, currentPage: 30); + final activities = (await library.snapshot()).activities; + expect(activities.map((activity) => activity.seconds).reduce((left, right) => left + right), 900); + final grouped = groupReadingActivities(activities); + expect(grouped.length, 1); + expect(grouped.single.seconds, 900); + expect(grouped.single.pages, 20); + expect(grouped.single.constituentIds.length, 2); + expect(activities.map((activity) => activity.startTime), contains(start.add(const Duration(minutes: 20)))); + }); + + test('digital book sessions persist without changing the saved reader position', () async { + final digital = Book( + id: 'digital', + title: 'Ebook', + author: 'Author', + addedAt: start, + currentPage: 12, + currentPosition: 0.3, + currentCfi: 'saved-reader-position', + ); + + await library.books.upsert(digital); + final timer = ReadingTimer.start(digital, start, 'device', [], targetSeconds: 300); + await library.writeTimer(timer); + await library.finishTimer(timer, start.add(const Duration(minutes: 10))); + final snapshot = await library.snapshot(); + final saved = snapshot.books.singleWhere((book) => book.id == digital.id); + expect(snapshot.activities.single.bookId, digital.id); + expect(snapshot.activities.single.seconds, 300); + expect(saved.currentPage, 12); + expect(saved.currentPosition, 0.3); + expect(saved.currentCfi, digital.currentCfi); + expect(await library.loadTimer(), isNull); + }); + + test('subsecond pauses retain the same saved total as the timer display', () async { + var timer = ReadingTimer.start(book, start, 'device', []); + timer = timer.stop(start.add(const Duration(milliseconds: 900))); + timer = timer.resume(start.add(const Duration(seconds: 2))); + final end = start.add(const Duration(milliseconds: 2900)); + await library.writeTimer(timer); + await library.finishTimer(timer, end); + final saved = groupReadingActivities((await library.snapshot()).activities).single; + expect(timer.elapsed(end).inSeconds, 1); + expect(saved.seconds, timer.elapsed(end).inSeconds); + }); + + test('negative page input rolls back and retains the draft for retry', () async { + final timer = ReadingTimer.start(book, start, 'device', []); + await library.writeTimer(timer); + final end = start.add(const Duration(minutes: 10)); + await expectLater(library.finishTimer(timer, end, currentPage: -1), throwsArgumentError); + expect((await library.loadTimer())!.id, timer.id); + expect((await library.snapshot()).activities, isEmpty); + await library.finishTimer(timer, end, currentPage: 100); + final snapshot = await library.snapshot(); + expect(snapshot.books.single.readingStatus, LibraryReadingStatus.completed); + expect(snapshot.activities.where((activity) => activity.kind == 'completion').length, 1); + }); + + for (final (page, pagesRead, position) in [(199, 189, 1.0), (5, 0, 0.05)]) { + test('saves page $page outside the prior page range', () async { + final timer = ReadingTimer.start(book, start, 'device', []); + await library.writeTimer(timer); + await library.finishTimer(timer, start.add(const Duration(minutes: 2)), currentPage: page); + final snapshot = await library.snapshot(); + expect(snapshot.books.single.currentPage, page); + expect(snapshot.books.single.pageCount, 100); + expect(snapshot.books.single.currentPosition, position); + expect(snapshot.activities.single.pages, pagesRead); + expect(snapshot.activities.single.seconds, 120); + expect(await library.loadTimer(), isNull); + }); + } + + test('stale windows cannot overwrite, discard or save a newer timer', () async { + final timer = ReadingTimer.start(book, start, 'device', []); + await library.writeTimer(timer); + final paused = timer.stop(start.add(const Duration(minutes: 2))); + await library.writeTimer(paused, previous: timer); + await expectLater(library.writeTimer(timer.stop(start), previous: timer), throwsStateError); + await expectLater(library.discardTimer(timer), throwsStateError); + await expectLater(library.finishTimer(timer, start.add(const Duration(minutes: 3))), throwsStateError); + expect((await library.loadTimer())!.revision, paused.revision); + }); + + test('same-numbered revisions from different device branches are not interchangeable', () async { + final timer = ReadingTimer.start(book, start, 'device', []); + await library.writeTimer(timer); + final accepted = timer.stop(start.add(const Duration(minutes: 1))); + final stale = timer.stop(start.add(const Duration(minutes: 2))); + expect(accepted.revision, stale.revision); + expect(accepted.version, isNot(stale.version)); + await library.writeTimer(accepted, previous: timer); + await expectLater(library.discardTimer(stale), throwsStateError); + await expectLater(library.finishTimer(stale, start.add(const Duration(minutes: 3))), throwsStateError); + expect((await library.loadTimer())!.version, accepted.version); + }); + + test('invalidated profile handle cannot read or mutate its timer', () async { + final timer = ReadingTimer.start(book, start, 'device', []); + await library.writeTimer(timer); + library.active = false; + await expectLater(library.loadTimer(), throwsStateError); + await expectLater(library.discardTimer(timer), throwsStateError); + await expectLater(library.finishTimer(timer, start.add(const Duration(minutes: 5))), throwsStateError); + library.active = true; + expect((await library.loadTimer())!.id, timer.id); + }); + + test('account timer transitions and saved history enter one upload transaction', () async { + await database.close(); + await open(account: true); + await library.books.upsert(book); + await database.execute('DELETE FROM ps_crud'); + final timer = ReadingTimer.start(book, start, 'device', [], targetSeconds: 60); + await library.writeTimer(timer); + final startTransaction = (await database.getNextCrudTransaction())!; + expect(startTransaction.crud.single.table, 'reading_timers'); + expect(startTransaction.crud.single.id, timer.id); + expect(startTransaction.crud.single.opData!['status'], 'active'); + await startTransaction.complete(); + await library.finishTimer(timer, start.add(const Duration(minutes: 3))); + expect(await library.loadTimer(), isNull); + expect((await library.snapshot()).activities.single.seconds, 60); + expect(await database.getAll('SELECT * FROM tracking_staging'), isEmpty); + final finishTransaction = (await database.getNextCrudTransaction())!; + expect(finishTransaction.crud.map((entry) => entry.table), ['reading_timers', 'reading_activities', 'books']); + expect(finishTransaction.crud.first.opData!['status'], 'saved'); + expect(finishTransaction.crud.first.opData!['base_version'], timer.version); + expect(await database.getNextCrudTransaction(), isNotNull); + }); + + test('capability discovery cannot split a timer save from its history upload', () async { + await database.close(); + await open(account: true); + await library.books.upsert(book); + await database.execute('DELETE FROM ps_crud'); + final timer = ReadingTimer.start(book, start, 'device', []); + await library.writeTimer(timer); + await library.finishTimer(timer, start.add(const Duration(minutes: 2))); + final auth = CapturingUploadRepository(); + + final connector = PapyrusPowerSyncConnector( + authRepository: auth, + config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://example.invalid')), + supportsTracking: () => false, + ); + + await connector.uploadData(database); + expect(auth.batches, hasLength(2)); + expect(auth.batches.last.map((entry) => entry['type']), ['reading_timers', 'reading_activities', 'books']); + expect((auth.batches.last.first['data'] as Map)['base_version'], timer.version); + expect((auth.batches.last.first['data'] as Map)['payload'], isA()); + expect(await database.getAll('SELECT * FROM tracking_staging'), isEmpty); + expect(await database.getAll('SELECT * FROM ps_crud'), isEmpty); + }); + + test('existing local timers migrate once without losing elapsed time', () async { + final timer = ReadingTimer.start(book, start, 'device', []).stop(start.add(const Duration(minutes: 3))); + final payload = timer.toJson()..remove('version'); + await database.execute('INSERT INTO reading_timer (id, payload) VALUES (?, ?)', ['active', jsonEncode(payload)]); + await library.migrateTimer(); + await library.migrateTimer(); + final restored = (await library.loadTimer())!; + expect(restored.id, timer.id); + expect(restored.elapsed(start.add(const Duration(hours: 1))), const Duration(minutes: 3)); + expect(await database.getAll('SELECT * FROM reading_timer'), isEmpty); + expect(await database.getAll('SELECT * FROM reading_timers'), hasLength(1)); + expect(await database.getAll('SELECT * FROM ps_crud'), isEmpty); + }); + + test('a terminal record disappears from watches and stays durable after reopening', () async { + final timer = ReadingTimer.start(book, start, 'device', []); + await library.writeTimer(timer); + final closed = library.watchTimer().firstWhere((value) => value == null); + await library.discardTimer(timer); + await closed.timeout(const Duration(seconds: 2)); + await database.close(); + await open(); + expect(await library.loadTimer(), isNull); + expect((await database.get('SELECT status FROM reading_timers WHERE id = ?', [timer.id]))['status'], 'discarded'); + }); + + test('deleted book leaves the timer recoverable without resurrecting the book', () async { + final timer = ReadingTimer.start(book, start, 'device', []); + await library.writeTimer(timer); + await library.books.delete(book.id); + await expectLater(library.finishTimer(timer, start.add(const Duration(minutes: 2))), throwsStateError); + expect((await library.loadTimer())!.id, timer.id); + expect((await library.snapshot()).books, isEmpty); + await library.discardTimer(timer); + expect(await library.loadTimer(), isNull); + }); +} diff --git a/app/test/providers/reading_timer_provider_test.dart b/app/test/providers/reading_timer_provider_test.dart new file mode 100644 index 00000000..53d5c857 --- /dev/null +++ b/app/test/providers/reading_timer_provider_test.dart @@ -0,0 +1,120 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/providers/reading_timer_provider.dart'; +import 'package:papyrus/timer/reading_timer.dart'; + +import '../timer/timer_test_library.dart'; + +void main() { + final now = DateTime.utc(2026, 11, 3, 12); + final book = Book(id: 'physical', title: 'Book', author: 'Author', addedAt: now, isPhysical: true); + + Future flush() => Future.delayed(Duration.zero); + + test('a delayed old-profile load cannot replace the new-profile timer', () async { + final old = TimerTestLibrary(); + final next = TimerTestLibrary(); + final oldGate = Completer(); + old.trackingRepository.loadGate = oldGate; + next.trackingRepository.timer = ReadingTimer.start(book, now, 'next', []); + final store = DataStore(bookRepository: old); + final timer = ReadingTimerProvider(store, now: () => now, tick: false); + addTearDown(timer.dispose); + addTearDown(store.dispose); + await flush(); + old.trackingRepository.isCurrent = false; + await store.attachBookRepository(next); + await flush(); + oldGate.complete(ReadingTimer.start(book, now, 'old', [])); + await flush(); + expect(timer.session!.deviceId, 'next'); + expect(timer.loading, isFalse); + }); + + test('switch during a pending start never copies the old draft into the new library', () async { + final old = TimerTestLibrary(); + final next = TimerTestLibrary(); + final store = DataStore(bookRepository: old); + final timer = ReadingTimerProvider(store, now: () => now, tick: false); + addTearDown(timer.dispose); + addTearDown(store.dispose); + await flush(); + final gate = Completer(); + old.trackingRepository.writeGate = gate; + final starting = timer.start(book); + old.trackingRepository.isCurrent = false; + await store.attachBookRepository(next); + await flush(); + gate.complete(); + expect(await starting, isFalse); + await flush(); + expect(timer.session, isNull); + expect(next.trackingRepository.timer, isNull); + expect(timer.busy, isFalse); + expect(timer.error, isNull); + }); + + test('a second start while persistence is pending cannot create another timer', () async { + final library = TimerTestLibrary(); + final store = DataStore(bookRepository: library); + final timer = ReadingTimerProvider(store, now: () => now, tick: false); + addTearDown(timer.dispose); + addTearDown(store.dispose); + await flush(); + final gate = Completer(); + library.trackingRepository.writeGate = gate; + final starting = timer.start(book); + expect(await timer.start(book), isFalse); + gate.complete(); + expect(await starting, isTrue); + expect(timer.session, isNotNull); + }); + + test('remote transitions update the same clock without resetting elapsed time', () async { + final library = TimerTestLibrary(); + final store = DataStore(bookRepository: library); + var clock = now.add(const Duration(minutes: 2)); + final timer = ReadingTimerProvider(store, now: () => clock, tick: false); + addTearDown(timer.dispose); + addTearDown(store.dispose); + await flush(); + final remote = ReadingTimer.start(book, now, 'browser', [], targetSeconds: 300); + await library.trackingRepository.writeTimer(remote); + await flush(); + expect(timer.session!.id, remote.id); + expect(timer.elapsed, const Duration(minutes: 2)); + expect(timer.running, isTrue); + final paused = remote.stop(clock); + await library.trackingRepository.writeTimer(paused, previous: remote); + clock = clock.add(const Duration(minutes: 4)); + await flush(); + expect(timer.running, isFalse); + expect(timer.elapsed, const Duration(minutes: 2)); + final resumed = paused.resume(clock); + await library.trackingRepository.writeTimer(resumed, previous: paused); + clock = clock.add(const Duration(minutes: 10)); + await flush(); + expect(timer.goalReached, isTrue); + expect(timer.elapsed, const Duration(minutes: 5)); + await library.trackingRepository.finishTimer(resumed, clock); + await flush(); + expect(timer.session, isNull); + }); + + test('deleting the local draft externally clears the displayed session', () async { + final library = TimerTestLibrary(); + final store = DataStore(bookRepository: library); + final timer = ReadingTimerProvider(store, now: () => now, tick: false); + addTearDown(timer.dispose); + addTearDown(store.dispose); + await flush(); + await timer.start(book); + await flush(); + await library.trackingRepository.discardTimer(timer.session!); + await flush(); + expect(timer.session, isNull); + }); +} diff --git a/app/test/services/reading_timer_notifications_test.dart b/app/test/services/reading_timer_notifications_test.dart new file mode 100644 index 00000000..897e6a0d --- /dev/null +++ b/app/test/services/reading_timer_notifications_test.dart @@ -0,0 +1,193 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/providers/reading_timer_provider.dart'; +import 'package:papyrus/services/reading_timer_notifications.dart'; +import 'package:papyrus/timer/reading_timer.dart'; + +import '../timer/timer_test_library.dart'; + +void main() { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('test/reading_timer_notifications'); + final start = DateTime.utc(2026, 11, 6, 12); + final book = Book(id: 'book', title: 'A book', author: 'Author', addedAt: start); + late TimerTestLibrary library; + late DataStore store; + late ReadingTimerProvider timer; + late DateTime now; + + Map action(String id, String kind, int seconds) => { + 'id': id, + 'kind': kind, + 'atMs': start.add(Duration(seconds: seconds)).millisecondsSinceEpoch, + }; + + Map batch(ReadingTimer original, List> actions) => { + 'sessionId': original.id, + 'version': original.version, + 'through': actions.last['id'], + 'actions': actions, + }; + + setUp(() async { + now = start; + library = TimerTestLibrary(); + store = DataStore(bookRepository: library); + timer = ReadingTimerProvider(store, now: () => now, tick: false); + await pumpEventQueue(); + await timer.start(book); + }); + + tearDown(() async { + timer.dispose(); + store.dispose(); + await library.trackingRepository.changes.close(); + binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); + }); + + test('background pause and resume use tap timestamps, not app resume time', () async { + final original = timer.session!; + now = start.add(const Duration(hours: 2)); + expect(await timer.applyNotificationActions(batch(original, [action('pause', 'pause', 30)])), isTrue); + expect(timer.elapsed, const Duration(seconds: 30)); + expect(timer.running, isFalse); + final paused = timer.session!; + expect(await timer.applyNotificationActions(batch(paused, [action('resume', 'resume', 120)])), isTrue); + expect(timer.session!.runningSince, start.add(const Duration(seconds: 120))); + }); + + test('queued finish saves only reading intervals once without page input', () async { + final original = timer.session!; + final pending = batch(original, [ + action('pause', 'pause', 30), + action('resume', 'resume', 90), + action('finish', 'finish', 150), + ]); + now = start.add(const Duration(hours: 2)); + expect(await timer.applyNotificationActions(pending), isTrue); + expect(library.trackingRepository.savedSeconds, 90); + expect(library.trackingRepository.savedPage, isNull); + expect(timer.session, isNull); + expect(await timer.applyNotificationActions(pending), isTrue); + expect(library.trackingRepository.saves, 1); + }); + + test('replay after a crash skips committed actions and completes finish', () async { + final original = timer.session!; + final pending = batch(original, [action('pause', 'pause', 30), action('finish', 'finish', 90)]); + final persisted = ReadingTimer.fromJson({ + ...original.stop(start.add(const Duration(seconds: 30))).toJson(), + 'version': 'pause', + }); + await library.trackingRepository.writeTimer(persisted); + await pumpEventQueue(); + now = start.add(const Duration(hours: 1)); + expect(await timer.applyNotificationActions(pending), isTrue); + expect(library.trackingRepository.savedSeconds, 30); + expect(library.trackingRepository.saves, 1); + }); + + test('replay after finish write retries the save without adding time', () async { + final original = timer.session!; + final pending = batch(original, [action('finish', 'finish', 30)]); + final persisted = ReadingTimer.fromJson({ + ...original.stop(start.add(const Duration(seconds: 30)), finish: true).toJson(), + 'version': 'finish', + }); + await library.trackingRepository.writeTimer(persisted); + await pumpEventQueue(); + now = start.add(const Duration(hours: 1)); + expect(await timer.applyNotificationActions(pending), isTrue); + expect(library.trackingRepository.savedSeconds, 30); + }); + + test('stale notification cannot overwrite a remotely changed timer', () async { + final original = timer.session!; + final remote = original.stop(start.add(const Duration(seconds: 10))); + await library.trackingRepository.writeTimer(remote); + await pumpEventQueue(); + expect(await timer.applyNotificationActions(batch(original, [action('finish', 'finish', 60)])), isTrue); + expect(timer.session, same(remote)); + expect(library.trackingRepository.saves, 0); + }); + + test('immediate finish discards quietly and countdown is capped at its goal', () async { + expect(await timer.applyNotificationActions(batch(timer.session!, [action('finish', 'finish', 0)])), isTrue); + expect(timer.session, isNull); + expect(library.trackingRepository.saves, 0); + await timer.start(book, targetSeconds: 60); + now = start.add(const Duration(minutes: 10)); + expect(await timer.applyNotificationActions(batch(timer.session!, [action('finish2', 'finish', 600)])), isTrue); + expect(library.trackingRepository.savedSeconds, 60); + }); + + test('native publication uses timestamps and does not update every tick', () async { + final published = >[]; + binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'publish') published.add(call.arguments as Map); + return null; + }); + final notifications = ReadingTimerNotifications(timer, onOpen: () {}, channel: channel, supported: true); + addTearDown(notifications.dispose); + await pumpEventQueue(); + expect(published.length, 1); + expect((published.last['snapshot'] as Map)['runningSinceMs'], start.millisecondsSinceEpoch); + now = start.add(const Duration(seconds: 15)); + await timer.reload(); + await pumpEventQueue(); + expect(published.length, 1); + await timer.pause(); + await pumpEventQueue(); + expect((published.last['snapshot'] as Map)['phase'], 'paused'); + expect((published.last['snapshot'] as Map)['recordedMs'], 15000); + }); + + test('failed storage keeps native actions queued until retry succeeds', () async { + Map? pending = batch(timer.session!, [action('pause', 'pause', 30)]); + var acknowledgements = 0; + binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'pending') return pending; + if (call.method == 'acknowledge') { + acknowledgements++; + pending = null; + } + return null; + }); + library.trackingRepository.failWrite = true; + final notifications = ReadingTimerNotifications(timer, onOpen: () {}, channel: channel, supported: true); + addTearDown(notifications.dispose); + await pumpEventQueue(); + expect(acknowledgements, 0); + expect(pending, isNotNull); + library.trackingRepository.failWrite = false; + now = start.add(const Duration(minutes: 3)); + notifications.didChangeAppLifecycleState(AppLifecycleState.resumed); + await pumpEventQueue(); + expect(acknowledgements, 1); + expect(timer.elapsed, const Duration(seconds: 30)); + }); + + test('switching profiles clears native state and rejects old controls', () async { + final original = timer.session!; + var clears = 0; + binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'clear') clears++; + return null; + }); + final notifications = ReadingTimerNotifications(timer, onOpen: () {}, channel: channel, supported: true); + addTearDown(notifications.dispose); + await pumpEventQueue(); + final next = TimerTestLibrary(); + addTearDown(next.trackingRepository.changes.close); + next.trackingRepository.timer = ReadingTimer.start(book, start, 'other', []); + library.trackingRepository.isCurrent = false; + await store.attachBookRepository(next); + await pumpEventQueue(); + expect(clears, greaterThan(0)); + expect(await timer.applyNotificationActions(batch(original, [action('finish', 'finish', 50)])), isTrue); + expect(next.trackingRepository.saves, 0); + }); +} diff --git a/app/test/timer/reading_timer_clock_test.dart b/app/test/timer/reading_timer_clock_test.dart new file mode 100644 index 00000000..fcdf0929 --- /dev/null +++ b/app/test/timer/reading_timer_clock_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/widgets/timer/reading_timer_clock.dart'; + +void main() { + for (final width in [200.0, 300.0]) { + for (final scale in [1.0, 1.3, 2.0]) { + testWidgets('clock labels stay anchored at width $width and text scale $scale', (tester) async { + Future show(Duration time) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark, + home: Scaffold( + body: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(scale)), + child: Center( + child: SizedBox( + width: width, + child: ReadingTimerClock(time: time, progress: 0.5, countdown: true, status: 'Ready'), + ), + ), + ), + ), + ), + ); + } + + await show(const Duration(minutes: 59, seconds: 59)); + final label = tester.getRect(find.text('Time remaining')); + final badge = tester.getRect(find.text('Ready')); + final clock = tester.getRect(find.byType(ReadingTimerClock)); + + for (final time in [ + const Duration(hours: 1), + const Duration(hours: 9, minutes: 59, seconds: 59), + const Duration(hours: 10), + const Duration(hours: 16, minutes: 40), + const Duration(hours: 100), + Duration.zero, + ]) { + await show(time); + expect(tester.getRect(find.text('Time remaining')), label); + expect(tester.getRect(find.text('Ready')), badge); + expect(tester.getRect(find.byType(ReadingTimerClock)), clock); + expect(tester.takeException(), isNull); + } + }); + } + } +} diff --git a/app/test/timer/reading_timer_history_test.dart b/app/test/timer/reading_timer_history_test.dart new file mode 100644 index 00000000..406ab875 --- /dev/null +++ b/app/test/timer/reading_timer_history_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/data/repositories/book_repository.dart'; +import 'package:papyrus/models/reading_activity.dart'; +import 'package:papyrus/widgets/timer/reading_timer_history.dart'; +import 'package:provider/provider.dart'; + +void main() { + final date = DateTime.utc(2026, 11, 6, 12); + late DataStore store; + + setUp(() => store = DataStore(bookRepository: InMemoryBookRepository())); + tearDown(() => store.dispose()); + + ReadingActivity entry( + String id, { + int minute = 0, + int seconds = 60, + String? sessionId, + String kind = 'reading', + String? correctionOf, + }) => ReadingActivity( + id: id, + bookId: 'book', + bookTitle: 'Book $id', + sessionId: sessionId, + deviceType: 'physical_timer', + startTime: date.add(Duration(minutes: minute)), + endTime: date.add(Duration(minutes: minute, seconds: seconds)), + createdAt: date, + kind: kind, + correctionOf: correctionOf, + ); + + Future pump(WidgetTester tester, {double width = 500, double scale = 1}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = Size(width, 900); + addTearDown(tester.view.reset); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: store, + child: MaterialApp( + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + home: const Scaffold( + body: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.all(24), + child: ReadingTimerHistory(), + ), + ), + ), + ), + ), + ); + } + + testWidgets('history groups intervals without counting pauses and orders newest first', (tester) async { + await store.commitTracking( + activities: [ + entry('first', seconds: 120, sessionId: 'shared'), + entry('second', minute: 10, seconds: 180, sessionId: 'shared'), + entry('latest', minute: 30, seconds: 42), + entry('completion', minute: 40, kind: 'completion'), + ], + ); + await pump(tester); + expect(find.text('Book first'), findsOneWidget); + expect(find.text('Book second'), findsNothing); + expect(find.text('Book completion'), findsNothing); + expect(find.text('5m'), findsOneWidget); + expect(find.text('42s'), findsOneWidget); + expect(tester.getTopLeft(find.text('Book latest')).dy, lessThan(tester.getTopLeft(find.text('Book first')).dy)); + }); + + testWidgets('history refreshes for saved and reversed entries', (tester) async { + await pump(tester); + expect(find.text('No saved sessions yet.'), findsOneWidget); + await store.commitTracking(activities: [entry('saved')]); + await tester.pump(); + expect(find.text('Book saved'), findsOneWidget); + expect(find.text('No saved sessions yet.'), findsNothing); + await store.commitTracking( + activities: [entry('reversal', kind: 'reversal', correctionOf: 'saved')], + ); + await tester.pump(); + expect(find.text('Book saved'), findsNothing); + expect(find.text('No saved sessions yet.'), findsOneWidget); + }); + + testWidgets('history limits older sessions and supports narrow large text', (tester) async { + await store.commitTracking(activities: [for (var index = 0; index < 10; index++) entry('$index', minute: index)]); + await pump(tester, width: 320, scale: 2); + expect(find.text('Book 9'), findsOneWidget); + expect(find.text('Book 2'), findsOneWidget); + expect(find.text('Book 1'), findsNothing); + expect(find.text('Book 0'), findsNothing); + expect(tester.takeException(), isNull); + }); +} diff --git a/app/test/timer/reading_timer_test.dart b/app/test/timer/reading_timer_test.dart new file mode 100644 index 00000000..c6c1f42d --- /dev/null +++ b/app/test/timer/reading_timer_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/timer/reading_timer.dart'; +import 'package:papyrus/timer/reading_timer_format.dart'; + +void main() { + final start = DateTime.utc(2026, 11, 3, 12); + final book = Book(id: 'digital', title: 'Digital book', author: 'Author', addedAt: start); + + test('clock supports sessions longer than an hour', () { + expect(readingTimerClock(const Duration(hours: 2, minutes: 3, seconds: 4)), '2:03:04'); + }); + + test('clock rollback does not produce negative reading intervals', () { + final timer = ReadingTimer.start(book, start, 'device', []); + expect(timer.elapsed(start.subtract(const Duration(minutes: 1))), Duration.zero); + expect(timer.activities(start.subtract(const Duration(minutes: 1))), isEmpty); + }); + + for (final target in [null, 60]) { + test('resume with a slower clock preserves ${target == null ? 'stopwatch' : 'countdown'} intervals', () { + final timer = ReadingTimer.start(book, start, 'device', [], targetSeconds: target); + final pausedAt = start.add(const Duration(seconds: 30)); + final paused = timer.stop(pausedAt); + final resumed = paused.resume(pausedAt.subtract(const Duration(seconds: 1))); + expect(resumed.runningSince, pausedAt); + expect(resumed.elapsed(pausedAt), const Duration(seconds: 30)); + final finished = resumed.stop(pausedAt.add(const Duration(seconds: 10)), finish: true); + expect(finished.intervals.last.start, finished.intervals.first.end); + expect(finished.recorded, const Duration(seconds: 40)); + }); + } + + test('resume without recorded intervals cannot precede session creation', () { + final paused = ReadingTimer.start(book, start, 'device', []).stop(start); + final resumed = paused.resume(start.subtract(const Duration(seconds: 1))); + expect(resumed.runningSince, start); + expect(resumed.elapsed(start), Duration.zero); + }); + + test('continuing a completed countdown clamps a slower clock to the goal time', () { + final timer = ReadingTimer.start(book, start, 'device', [], targetSeconds: 60); + final goalTime = start.add(const Duration(minutes: 1)); + final finished = timer.stop(goalTime); + final resumed = finished.resume(goalTime.subtract(const Duration(seconds: 1)), keepReading: true); + expect(resumed.targetSeconds, isNull); + expect(resumed.runningSince, goalTime); + expect(resumed.elapsed(goalTime), const Duration(minutes: 1)); + }); + + test('countdown stops at its goal and continuation excludes unattended time', () { + final timer = ReadingTimer.start(book, start, 'device', [], targetSeconds: 60); + final returnTime = start.add(const Duration(minutes: 5)); + expect(timer.elapsed(returnTime), const Duration(minutes: 1)); + expect(timer.reachedGoal(returnTime), isTrue); + expect(timer.isRunning(returnTime), isFalse); + final continued = timer.stop(returnTime).resume(returnTime, keepReading: true); + final finishTime = returnTime.add(const Duration(minutes: 2)); + final finished = continued.stop(finishTime, finish: true); + expect(finished.targetSeconds, isNull); + expect(finished.elapsed(finishTime), const Duration(minutes: 3)); + expect(finished.phase, ReadingTimerPhase.finished); + }); +} diff --git a/app/test/timer/timer_test_library.dart b/app/test/timer/timer_test_library.dart new file mode 100644 index 00000000..93242d6b --- /dev/null +++ b/app/test/timer/timer_test_library.dart @@ -0,0 +1,71 @@ +import 'dart:async'; + +import 'package:papyrus/data/repositories/book_repository.dart'; +import 'package:papyrus/data/repositories/reading_timer_repository.dart'; +import 'package:papyrus/data/repositories/tracking_repository.dart'; +import 'package:papyrus/timer/reading_timer.dart'; + +class TimerTestLibrary extends InMemoryBookRepository implements TrackingRepositoryOwner { + @override + final TimerTestRepository trackingRepository = TimerTestRepository(); +} + +class TimerTestRepository implements TrackingRepository, ReadingTimerRepository { + @override + bool isCurrent = true; + ReadingTimer? timer; + int? savedPage; + int? savedSeconds; + bool failWrite = false; + int saves = 0; + Completer? writeGate; + Completer? loadGate; + final changes = StreamController.broadcast(); + + @override + Future loadTimer() async => loadGate == null ? timer : await loadGate!.future; + + @override + Stream watchTimer() async* { + yield await loadTimer(); + yield* changes.stream; + } + + @override + Future writeTimer(ReadingTimer value, {ReadingTimer? previous}) async { + await writeGate?.future; + + if (failWrite) { + throw StateError('Save failed. Try again.'); + } + + if (!isCurrent) { + throw StateError('Library changed.'); + } + + timer = value; + changes.add(timer); + } + + @override + Future discardTimer(ReadingTimer timer) async { + this.timer = null; + changes.add(null); + } + + @override + Future finishTimer(ReadingTimer timer, DateTime now, {int? currentPage}) async { + if (failWrite) { + throw StateError('Save failed. Try again.'); + } + + saves++; + savedPage = currentPage; + savedSeconds = timer.elapsed(now).inSeconds; + this.timer = null; + changes.add(null); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/app/test/widgets/shared/app_page_bar_test.dart b/app/test/widgets/shared/app_page_bar_test.dart index a0166a33..321104f3 100644 --- a/app/test/widgets/shared/app_page_bar_test.dart +++ b/app/test/widgets/shared/app_page_bar_test.dart @@ -44,6 +44,115 @@ void main() { } } + for (final theme in [AppTheme.light, AppTheme.dark, AppTheme.eink]) { + testWidgets('inline header matches app bar scrolling in ${theme.brightness} ${theme.appBarTheme.shape}', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(400, 850); + addTearDown(tester.view.reset); + final scroll = ScrollController(); + addTearDown(scroll.dispose); + final surfaces = <(Color?, double, Color?, Color?, ShapeBorder?)>[]; + + for (final inline in [false, true]) { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Builder( + builder: (context) { + final body = ListView( + controller: scroll, + children: const [SizedBox(height: 2000)], + ); + + return Scaffold( + appBar: inline ? null : AppPageBar(context: context, title: const Text('Title')), + body: inline + ? Column( + children: [ + const AppPageToolbar(content: Text('Title')), + Expanded(child: body), + ], + ) + : body, + ); + }, + ), + ), + ); + + scroll.jumpTo(0); + await tester.pumpAndSettle(); + final surface = find + .descendant( + of: inline ? find.byType(AppPageToolbar) : find.byType(AppBar), + matching: find.byType(Material), + ) + .first; + final before = tester.widget(surface); + final bounds = tester.getRect(find.byType(AppPageToolbar)); + scroll.jumpTo(200); + await tester.pumpAndSettle(); + final after = tester.widget(surface); + expect(after.elevation, theme.appBarTheme.scrolledUnderElevation); + expect(tester.getRect(find.byType(AppPageToolbar)), bounds); + surfaces.add((after.color, after.elevation, after.surfaceTintColor, after.shadowColor, after.shape)); + scroll.jumpTo(0); + await tester.pumpAndSettle(); + expect(tester.widget(surface).elevation, before.elevation); + expect(tester.takeException(), isNull); + } + + expect(surfaces[1], surfaces[0]); + }); + } + + testWidgets('inline header ignores horizontal and nested scrolling', (tester) async { + final horizontal = ScrollController(); + final nested = ScrollController(); + addTearDown(horizontal.dispose); + addTearDown(nested.dispose); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark, + home: Scaffold( + body: Column( + children: [ + const AppPageToolbar(content: Text('Title')), + SizedBox( + height: 100, + child: ListView( + controller: horizontal, + scrollDirection: Axis.horizontal, + children: const [SizedBox(width: 2000)], + ), + ), + Expanded( + child: ListView( + children: [ + SizedBox( + height: 300, + child: ListView(controller: nested, children: const [SizedBox(height: 2000)]), + ), + ], + ), + ), + ], + ), + ), + ), + ); + + final surface = find.descendant(of: find.byType(AppPageToolbar), matching: find.byType(Material)).first; + horizontal.jumpTo(200); + nested.jumpTo(200); + await tester.pumpAndSettle(); + expect(tester.widget(surface).elevation, 0); + expect(tester.takeException(), isNull); + }); + testWidgets('full screen routes retain automatic close and custom colors', (tester) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = const Size(400, 850); diff --git a/docs/TIMER_NOTIFICATIONS.md b/docs/TIMER_NOTIFICATIONS.md new file mode 100644 index 00000000..0d3d3c8f --- /dev/null +++ b/docs/TIMER_NOTIFICATIONS.md @@ -0,0 +1,66 @@ +# Reading timer notifications + +The native surfaces mirror the existing reading timer. They do not own a second +PowerSync database or create their own reading sessions. + +- Android shows a silent notification with the book, a native chronometer, + pause/resume and finish. Running timers use a `specialUse` foreground service. + Pausing stops the service but retains the ongoing notification and its controls. + Android 13+ asks for notification permission when an active timer is first shown. +- iOS 16.2+ shows a Live Activity on the Lock Screen and Dynamic Island. iOS 17+ + provides pause/resume and finish through `LiveActivityIntent`, without opening + the app. Earlier supported versions link back to the timer. ActivityKit and the + user's system settings control visibility and lifetime. +- Finish saves time without page input. Subsecond sessions are discarded quietly. + Countdown time is capped at the goal. A completed countdown offers Save. +- Other platforms retain the in-app timer and do not request notification access. + +## Background actions and synchronization + +Native controls first write a private, durable action journal containing the +session ID, base version, command IDs and tap timestamps. They update the native +surface immediately. Dart replays those actions through `ReadingTimerProvider` +and the current profile's repository when it is available. On a suspended or +terminated iPhone app, replay may wait until the app next runs. PowerSync receives +the normal timer and reading-activity writes at that point. + +Command IDs become timer versions, making replay recoverable if the process stops +between persisting an action and acknowledging it. A remote version change or a +different active session rejects stale controls. Switching library profiles clears +the old native surface and journal. Native acknowledgements preserve newer actions +that arrived while Dart was writing. + +The clock renders from timestamps rather than per-second native channel calls. +iOS does not keep a Dart engine or network connection running indefinitely. Remote +PowerSync changes update the Live Activity when the app is running again. There is +no APNs push-to-update integration. Android does not acquire a wake lock. On screen +wake, its service refreshes countdown completion. + +## Validation + +Dart tests cover delayed timestamps, save-once replay, interrupted finish recovery, +stale remote versions, subsecond finish, countdown limits, persistence failures and +profile isolation. Native XCTest covers the durable journal, concurrent +acknowledgement, countdown clamping and real Live Activity intent execution. + +The native smoke-test host at +`app/integration_test/support/timer_notification_app.dart` uses synthetic data and +no user library. Build it only for isolated test simulators/emulators. The normal +application entry point remains `app/lib/main.dart`. Use the fixture for native +XCTest so the production provider does not clear synthetic sessions during a test. +From `app/ios`, with an isolated iOS simulator booted: + +```sh +xcodebuild test -workspace Runner.xcworkspace -scheme Runner \ + -destination 'platform=iOS Simulator,name=YOUR_TEST_SIMULATOR' \ + -only-testing:RunnerTests -parallel-testing-enabled NO CODE_SIGNING_ALLOWED=NO \ + FLUTTER_TARGET=integration_test/support/timer_notification_app.dart +``` + +Use `flutter run -t integration_test/support/timer_notification_app.dart` on an +isolated Android emulator to check notification actions after backgrounding or +killing the app. The fixture is never the release entry point. + +Android special-use foreground service declarations must be included in the Play +Console declaration when this feature is distributed. The widget extension is +embedded in the iOS Runner target and uses the app's build version and signing team.