diff --git a/.gitignore b/.gitignore index 9f009562..29e75621 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ test/data/ app/pubspec_overrides.yaml __pycache__/ *.pyc +.impeccable/ diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000..2e110e7e --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,192 @@ +--- +name: "Papyrus" +description: "Existing Flutter visual system for the cross-platform library and reader." +colors: + light-primary: "#5654A8" + light-on-primary: "#FFFFFF" + light-primary-container: "#E2DFFF" + light-on-primary-container: "#100563" + light-secondary: "#5D5C71" + light-secondary-container: "#E3E0F9" + light-on-secondary-container: "#1A1A2C" + light-tertiary: "#7A5368" + light-surface: "#FFFBFF" + light-on-surface: "#1C1B1F" + light-surface-container-highest: "#E4E1EC" + light-on-surface-variant: "#47464F" + light-outline: "#787680" + light-outline-variant: "#C8C5D0" + light-error: "#BA1A1A" + dark-primary: "#C3C0FF" + dark-on-primary: "#272377" + dark-primary-container: "#3E3C8F" + dark-on-primary-container: "#E2DFFF" + dark-secondary: "#C7C4DD" + dark-secondary-container: "#464559" + dark-on-secondary-container: "#E3E0F9" + dark-tertiary: "#EAB9D2" + dark-surface: "#1C1B1F" + dark-on-surface: "#E5E1E6" + dark-surface-container-highest: "#47464F" + dark-on-surface-variant: "#C8C5D0" + dark-outline: "#928F9A" + dark-outline-variant: "#47464F" + dark-error: "#FFB4AB" + eink-primary: "#000000" + eink-on-primary: "#FFFFFF" + eink-primary-container: "#000000" + eink-on-primary-container: "#FFFFFF" + eink-secondary: "#404040" + eink-secondary-container: "#F5F5F5" + eink-on-secondary-container: "#000000" + eink-tertiary: "#404040" + eink-surface: "#FFFFFF" + eink-on-surface: "#000000" + eink-surface-container-highest: "#F5F5F5" + eink-on-surface-variant: "#404040" + eink-outline: "#000000" + eink-outline-variant: "#000000" + eink-error: "#000000" + eink-black: "#000000" + eink-white: "#FFFFFF" + eink-dark-gray: "#404040" + eink-light-gray: "#C0C0C0" + eink-container: "#F5F5F5" +typography: + display-large: {"fontSize": "57px", "fontWeight": 400, "letterSpacing": "-0.25px"} + display-medium: {"fontSize": "45px", "fontWeight": 400, "letterSpacing": "0px"} + display-small: {"fontSize": "36px", "fontWeight": 400, "letterSpacing": "0px"} + headline-large: {"fontSize": "32px", "fontWeight": 400, "letterSpacing": "0px"} + headline-medium: {"fontSize": "28px", "fontWeight": 400, "letterSpacing": "0px"} + headline-small: {"fontSize": "24px", "fontWeight": 400, "letterSpacing": "0px"} + title-large: {"fontSize": "22px", "fontWeight": 400, "letterSpacing": "0px"} + title-medium: {"fontSize": "16px", "fontWeight": 500, "letterSpacing": "0.15px"} + title-small: {"fontSize": "14px", "fontWeight": 500, "letterSpacing": "0.1px"} + body-large: {"fontSize": "16px", "fontWeight": 400, "letterSpacing": "0.5px"} + body-medium: {"fontSize": "14px", "fontWeight": 400, "letterSpacing": "0.25px"} + body-small: {"fontSize": "12px", "fontWeight": 400, "letterSpacing": "0.4px"} + label-large: {"fontSize": "14px", "fontWeight": 500, "letterSpacing": "0.1px"} + label-medium: {"fontSize": "12px", "fontWeight": 500, "letterSpacing": "0.5px"} + label-small: {"fontSize": "11px", "fontWeight": 500, "letterSpacing": "0.5px"} +rounded: + none: "0px" + sm: "4px" + md: "8px" + lg: "12px" + xl: "16px" + xxl: "28px" + full: "9999px" +spacing: + xs: "4px" + sm: "8px" + md: "16px" + lg: "24px" + xl: "32px" + xxl: "48px" +components: + button-outlined: {"rounded": "{rounded.md}", "padding": "12px 24px"} + button-text: {"padding": "8px 12px"} + input: {"rounded": "{rounded.sm}", "padding": "16px"} + card: {"rounded": "{rounded.lg}"} + button-eink: {"backgroundColor": "{colors.eink-black}", "textColor": "{colors.eink-white}", "rounded": "{rounded.none}", "padding": "12px 24px"} + button-outlined-eink: {"backgroundColor": "{colors.eink-white}", "textColor": "{colors.eink-black}", "rounded": "{rounded.none}", "padding": "12px 24px"} + input-eink: {"backgroundColor": "{colors.eink-container}", "textColor": "{colors.eink-black}", "rounded": "{rounded.none}", "padding": "16px"} + chip-eink: {"backgroundColor": "{colors.eink-white}", "textColor": "{colors.eink-black}"} + chip-eink-selected: {"backgroundColor": "{colors.eink-light-gray}", "textColor": "{colors.eink-black}"} +--- + +# Design System: Papyrus + +## Overview + +Papyrus uses its existing Material 3 theme for a readable, adaptive library and reader. Muted violet accents sit on neutral surfaces, with separate light, dark, high-contrast and e-ink themes. This record describes the implemented system, without introducing a new visual identity. + +Sources: `app/lib/themes/app_theme.dart`, `design_tokens.dart`, +`color_schemes.g.dart` and `app_motion.dart`. Flutter source remains authoritative. +Frontmatter `px` values represent Flutter logical pixels. Mode prefixes identify +actual palette variants, not interchangeable colors within one theme. + +**Key Characteristics:** +- Semantic theme colors and text roles. +- Shared spacing and shape tokens. +- Dedicated e-ink and reduced-motion behavior. + +## Colors + +The primary accent is muted violet in light mode and pale violet in dark mode. +Secondary colors support tonal controls. Tertiary colors provide a muted rose +accent. Surfaces, text and outlines use the corresponding semantic neutral roles. +E-ink resolves these roles to solid grayscale colors. + +The frontmatter records explicit source values. Surface-container roles omitted +from `color_schemes.g.dart`, including `surfaceContainerLow`, retain Flutter's +runtime defaults and are deliberately not assigned inferred hex values here. +High-contrast schemes are generated from the primary seed with contrast level 1. + +## Typography + +The frontmatter mirrors the explicit Material text scale. The theme does not +specify a custom default font family or line heights. Flutter resolves those +values. Headline, title, body and label roles provide hierarchy without a new +brand font. + +E-ink strengthens body-medium/body-small to weight 500 and label-medium/label-small +to 600. Accessibility settings can substitute OpenDyslexic and promote text to +bold. Components inherit these adaptations through the active text theme. + +## Layout + +The shared spacing scale supplies gaps, padding and margins. Breakpoint tokens +start tablet at 600, small desktop at 840 and large desktop at 1200 logical pixels. +They define 4/8/12 grid columns and 16/24/24 gutters for mobile/tablet/desktop. +Actual pages choose their composition within available width and text scaling. +Statistics defaults to left alignment with shared page margins. Its optional +center alignment limits content width to 1120 logical pixels. + +## Elevation & Depth + +Standard themes combine Material elevation and tonal surfaces. Shared cards and +elevated buttons use elevation 1, bottom navigation 3 and popup menus 6. These +are Flutter elevation values, not CSS shadow recipes. E-ink component themes use +zero elevation and visible borders. Motion durations live in the sidecar. +`AppMotion` disables motion when requested by the theme or platform settings. + +## Shapes + +Shared buttons use the md radius, inputs sm, cards lg, dialogs xxl and bottom +sheets xl. E-ink button, input and card themes have square corners. Default input +borders are 1 logical pixel, increasing to 2 for focus/error. E-ink input borders +use 2 at rest and 4 for focus/error. E-ink card themes use a 2-pixel border. + +## Components + +- **Buttons:** elevated and outlined themes use shared horizontal/vertical + padding, minimum height 50, and 16-point medium text. Text buttons have smaller + padding. E-ink buttons become square, instant and semibold. Standard interactive + state layers otherwise come from Flutter Material. +- **Inputs:** outlined fields use semantic outline, primary focus and error + colors. E-ink fields use a solid container fill and persistent bold labels. +- **Cards:** standard shared cards have a small elevation and rounded shape. + Statistics section containers are flat, use `surfaceContainerLow` and a + semantic outline, and inherit the card shape. Their local 1-pixel border is + an implementation detail, not an override of the shared e-ink card theme. +- **Chips:** standard choices inherit Material styling. The e-ink theme uses + white or selected light-gray fills, black text and 2-pixel black borders. +- **Navigation:** bottom navigation maps selected items to primary and other + items to on-surface-variant. E-ink navigation removes elevation and strengthens + selected labels. App bars use the surface and on-surface roles. + +The sidecar's small HTML/CSS examples preview explicit e-ink theme properties. +They are reference swatches, not substitutes for Flutter widgets or their full +platform interaction behavior. + +## Do's and Don'ts + +### Do: +- Do resolve colors and typography through the active Flutter theme. +- Do preserve text scaling, accessibility typography and reduced-motion settings. +- Do use existing spacing and radius tokens for shared components. + +### Don't: +- Don't hard-code the light palette into adaptive widgets. +- Don't carry standard-theme elevation and animation into e-ink components. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 00000000..b003810f --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,28 @@ +# Product + + + +## Platform + +adaptive + +## Product Purpose + +Papyrus is an offline-first, cross-platform book library and reader. The reading +statistics page helps readers understand their reading habits. This priority was +confirmed for the statistics redesign. + +## Capabilities and Constraints + +The Flutter client uses Provider and DataStore. Preserve guest, user and server +profile isolation and durable reading activity. Statistics must reflect existing +provider calculations, with explicit labels for different date scopes. + +## Accessibility & Inclusion + +Preserve light, dark and e-ink themes, reduced motion, keyboard access, text scaling +and responsive layouts. Follow existing design tokens and shared controls. + +## Open Decisions + +No broader audience or brand changes were requested in this page redesign. 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..a9dc022a --- /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 + val committed = snapshot?.optString("id") == state.optJSONObject("base")?.optString("id") && + snapshot?.optString("version") == through + write(JSONObject().put("base", snapshot ?: JSONObject.NULL) + .put("actions", if (committed) 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/app_updates_test.dart b/app/integration_test/app_updates_test.dart new file mode 100644 index 00000000..665f7b08 --- /dev/null +++ b/app/integration_test/app_updates_test.dart @@ -0,0 +1,131 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/updates/update_controller.dart'; +import 'package:papyrus/updates/update_release.dart'; +import 'package:papyrus/widgets/profile/about_settings_section.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('update settings navigation and states on mobile', (tester) async { + for (final (name, theme, scale) in [ + ('light', AppTheme.light, 1.0), + ('dark', AppTheme.dark, 1.0), + ('large-text', AppTheme.dark, 2.0), + ('eink', AppTheme.eink, 1.0), + ]) { + SharedPreferences.setMockInitialValues({}); + var result = Completer>(); + final installation = Completer(); + final updates = UpdateController( + preferences: await SharedPreferences.getInstance(), + appInfo: () async => + PackageInfo(appName: 'Papyrus', packageName: 'papyrus', version: '0.3.0', buildNumber: '7'), + channelProvider: () async => UpdateChannel.windows, + manifest: () => result.future, + installer: (_, progress) async { + progress(0.5); + return installation.future; + }, + ); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: updates, + child: MaterialApp( + key: ValueKey(name), + debugShowCheckedModeBanner: false, + theme: theme, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + home: const Scaffold(body: SafeArea(child: AboutSettingsSection())), + ), + ), + ); + + await tester.pumpAndSettle(); + await tester.tap(find.text('App updates')); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsNothing); + expect(find.text('Not checked yet'), findsOneWidget); + await binding.takeScreenshot('updates-$name-unchecked'); + await tester.tap(find.text('Check automatically')); + await tester.pumpAndSettle(); + expect(updates.automatic, isFalse); + + if (name == 'dark') { + await tester.tap(find.text('Check for updates')); + await tester.pump(const Duration(milliseconds: 300)); + await binding.takeScreenshot('updates-dark-checking'); + result.completeError(StateError('Offline')); + await tester.pumpAndSettle(); + expect(find.text('Couldn’t check for updates'), findsOneWidget); + await tester.pump(kThemeChangeDuration); + await binding.takeScreenshot('updates-dark-error'); + final retryButton = find.byKey(const ValueKey('check-updates')); + final retryBounds = tester.getRect(retryButton); + final toggleBounds = tester.getRect(find.byType(Switch)); + result = Completer>(); + await tester.tap(find.text('Retry')); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('Retry'), findsOneWidget); + expect(find.text('Please try again.'), findsNothing); + expect(find.byType(LinearProgressIndicator), findsNothing); + expect(tester.getRect(retryButton), retryBounds); + expect(tester.getRect(find.byType(Switch)), toggleBounds); + result.completeError(StateError('Still offline')); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 350)); + expect(find.byType(LinearProgressIndicator), findsNothing); + expect(tester.getRect(retryButton), retryBounds); + await binding.takeScreenshot('updates-dark-quick-retry'); + result = Completer>(); + await tester.tap(find.text('Retry')); + await tester.pump(); + result.complete(_release(7)); + await tester.pumpAndSettle(); + expect(find.text('You’re up to date'), findsOneWidget); + await binding.takeScreenshot('updates-dark-current'); + result = Completer>(); + await tester.tap(find.text('Check for updates')); + await tester.pump(); + result.complete(_release(8)); + await tester.pumpAndSettle(); + await binding.takeScreenshot('updates-dark-available'); + await tester.tap(find.text('Update')); + await tester.pump(const Duration(milliseconds: 300)); + await binding.takeScreenshot('updates-dark-progress'); + installation.complete('Close Papyrus normally to install the update.'); + await tester.pumpAndSettle(); + await binding.takeScreenshot('updates-dark-staged'); + } + + expect(tester.takeException(), isNull); + + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(find.text('Version'), findsOneWidget); + expect(updates.viewingSettings, isFalse); + await tester.pumpWidget(const SizedBox.shrink()); + updates.dispose(); + } + }); +} + +Map _release(int build) => { + 'tag': 'v0.3.0+$build', + 'version': '0.3.0', + 'build_number': build, + 'assets': { + 'installer': {'name': 'papyrus-v0.3.0+$build-windows-x64-setup.exe', 'sha256': 'a' * 64}, + }, +}; diff --git a/app/integration_test/mobile_settings_test.dart b/app/integration_test/mobile_settings_test.dart new file mode 100644 index 00000000..a9583244 --- /dev/null +++ b/app/integration_test/mobile_settings_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:papyrus/main.dart' as app; +import 'package:papyrus/pages/profile_page.dart'; +import 'package:papyrus/pages/licenses_page.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:papyrus/widgets/settings/settings_row.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('mobile settings groups, saved preferences, and native Back', (tester) async { + SharedPreferences.setMockInitialValues({'offline_mode': true, 'theme_mode': 'light'}); + await app.main(); + await tester.pumpAndSettle(); + final router = GoRouter.of(tester.element(find.byType(Scaffold).first)); + router.go('/profile'); + await tester.pumpAndSettle(); + final preferences = tester.element(find.byType(ProfilePage)).read(); + await binding.takeScreenshot('settings-index-light'); + + for (final (title, control) in [ + ('Account', 'Display name'), + ('Appearance', 'Theme'), + ('Storage', 'Clear local library'), + ('Accessibility', 'Reduce animations'), + ('About', 'Version'), + ]) { + await tester.ensureVisible(find.widgetWithText(ListTile, title)); + await tester.tap(find.widgetWithText(ListTile, title)); + await tester.pumpAndSettle(); + expect(find.text(control), findsOneWidget); + final route = ModalRoute.of(tester.element(find.byType(ProfilePage).last))! as TransitionRoute; + expect(route.transitionDuration, Duration.zero); + expect(route.reverseTransitionDuration, Duration.zero); + await binding.takeScreenshot('settings-${title.toLowerCase()}-light'); + + if (title == 'About') { + await tester.tap(find.widgetWithText(SettingsRow, 'Licenses')); + + for (var frame = 0; frame < 300; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + + if (find.byType(LicensesPage).evaluate().isNotEmpty) { + break; + } + } + + expect(find.byType(LicensesPage), findsOneWidget); + expect(find.byType(ListTile), findsWidgets); + expect(find.text('Loading licenses…'), findsNothing); + await binding.takeScreenshot('settings-licenses-first-frame'); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(find.text('Version'), findsOneWidget); + } + + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(router.routeInformationProvider.value.uri.path, '/profile'); + } + + await tester.tap(find.widgetWithText(ListTile, 'Appearance')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(SettingsRow, 'Theme')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Dark')); + await tester.pumpAndSettle(); + expect(preferences.themeModePref, 'dark'); + await binding.takeScreenshot('settings-appearance-dark'); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(router.routeInformationProvider.value.uri.path, '/profile'); + expect(find.text('Dark theme, page layout'), findsOneWidget); + tester.state(find.byType(Scrollable).first).position.jumpTo(0); + await tester.pumpAndSettle(); + await binding.takeScreenshot('settings-index-dark'); + preferences.interfaceTextEnlargement = 2; + await tester.pumpAndSettle(); + await binding.takeScreenshot('settings-index-large-text'); + await tester.ensureVisible(find.widgetWithText(ListTile, 'Accessibility')); + await tester.tap(find.widgetWithText(ListTile, 'Accessibility')); + await tester.pumpAndSettle(); + await binding.takeScreenshot('settings-accessibility-large-text'); + expect(tester.takeException(), isNull); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + preferences.interfaceTextEnlargement = 1; + preferences.reduceAnimations = true; + await tester.pumpAndSettle(); + router.go('/profile/appearance'); + await tester.pumpAndSettle(); + final route = ModalRoute.of(tester.element(find.byType(ProfilePage).last))! as TransitionRoute; + expect(route.transitionDuration, Duration.zero); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(router.routeInformationProvider.value.uri.path, '/profile'); + preferences.reduceAnimations = false; + preferences.themeModePref = 'eink'; + await tester.pumpAndSettle(); + tester.state(find.byType(Scrollable).first).position.jumpTo(0); + await tester.pumpAndSettle(); + await binding.takeScreenshot('settings-index-eink'); + await tester.tap(find.widgetWithText(ListTile, 'Appearance')); + await tester.pump(); + expect(find.text('Theme'), findsOneWidget); + await tester.pumpAndSettle(); + await binding.takeScreenshot('settings-appearance-eink'); + expect(tester.takeException(), isNull); + }); +} 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..7fd84532 100644 --- a/app/ios/RunnerTests/RunnerTests.swift +++ b/app/ios/RunnerTests/RunnerTests.swift @@ -1,12 +1,88 @@ -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 testRejectedAcknowledgementDiscardsSuccessorsFromTheOldVersion() throws { + var journal = TimerJournal(base: snapshot()) + try journal.record(sessionId: "native-test", token: "v1", kind: "pause") + let pause = journal.actions[0] + try journal.record(sessionId: "native-test", token: pause.id, kind: "finish") + var remote = snapshot() + remote.version = "remote-version" + remote.phase = "paused" + remote.runningSinceMs = nil + remote.recordedMs = 10_000 + try journal.acknowledge(through: pause.id, snapshot: remote) + journal = try TimerJournal.load() + XCTAssertTrue(journal.actions.isEmpty) + XCTAssertEqual(journal.base, remote) + XCTAssertEqual(journal.view()?.phase, "paused") + XCTAssertEqual(journal.view()?.recordedMs, 10_000) + try TimerJournal().save() + } + + 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..6a2e5180 --- /dev/null +++ b/app/ios/TimerActivity/TimerActivityState.swift @@ -0,0 +1,155 @@ +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 } + let committed = snapshot?.id == base?.id && snapshot?.version == through + actions = committed ? 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..e23e5167 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'; @@ -19,6 +20,7 @@ import 'package:papyrus/pages/profile_page.dart'; import 'package:papyrus/pages/licenses_page.dart'; import 'package:papyrus/pages/support_page.dart'; import 'package:papyrus/services/support_api_client.dart'; +import 'package:papyrus/services/license_packages.dart'; import 'package:papyrus/pages/register_page.dart'; import 'package:papyrus/pages/reader_page.dart'; import 'package:papyrus/pages/search_options_page.dart'; @@ -212,6 +214,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', @@ -283,9 +290,20 @@ class AppRouter { path: '/profile', pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const ProfilePage()), routes: [ + for (final section in ProfileSection.values) + GoRoute( + path: section.path, + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: ProfilePage(section: section), + ), + ), GoRoute( path: 'licenses', - pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const LicensesPage()), + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: LicensesPage(initialLicenses: state.extra as LicensePackages?), + ), ), GoRoute( path: 'support', 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/book.dart b/app/lib/models/book.dart index fb02a9be..6c6325f9 100644 --- a/app/lib/models/book.dart +++ b/app/lib/models/book.dart @@ -117,6 +117,11 @@ class Book { this.lastReadAt, }); + String? get genre { + final value = customMetadata?['genre']; + return value is String && value.trim().isNotEmpty ? value.trim() : null; + } + /// Progress as a percentage (0-100). int get progressPercent => (currentPosition * 100).round(); diff --git a/app/lib/models/reading_activity.dart b/app/lib/models/reading_activity.dart index 87c05f42..b303dcba 100644 --- a/app/lib/models/reading_activity.dart +++ b/app/lib/models/reading_activity.dart @@ -1,6 +1,6 @@ import 'package:papyrus/models/reading_session.dart'; -/// Stable document coverage, independent of the current screen's pagination. +/// Coverage of an identified reader page, deduplicated across checkpoints. class PageCoverage { const PageCoverage({ required this.key, @@ -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/app_updates_page.dart b/app/lib/pages/app_updates_page.dart new file mode 100644 index 00000000..08c41ea0 --- /dev/null +++ b/app/lib/pages/app_updates_page.dart @@ -0,0 +1,315 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:papyrus/services/about_links.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/updates/update_controller.dart'; +import 'package:papyrus/updates/update_release.dart'; +import 'package:papyrus/widgets/settings/settings_row.dart'; +import 'package:papyrus/widgets/library/library_page_header.dart'; +import 'package:papyrus/widgets/shared/app_page_bar.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; + +class AppUpdatesPage extends StatefulWidget { + const AppUpdatesPage({super.key, required this.controller}); + + final UpdateController controller; + + @override + State createState() => _AppUpdatesPageState(); +} + +typedef _UpdatePresentation = ({ + String title, + String? detail, + String action, + DateTime? lastChecked, + bool externalLink, +}); + +class _AppUpdatesPageState extends State { + static const _feedbackDelay = Duration(milliseconds: 300); + static const _checkFailedTitle = 'Couldn’t check for updates'; + + late final Future _installedVersion = widget.controller.appInfo(); + late _UpdatePresentation _presentation; + Timer? _feedbackTimer; + bool _showCheckFeedback = false; + + @override + void initState() { + super.initState(); + _presentation = _currentPresentation(); + widget.controller.addListener(_handleUpdate); + + if (widget.controller.checking) { + _scheduleFeedback(); + } + } + + @override + void dispose() { + widget.controller.removeListener(_handleUpdate); + _feedbackTimer?.cancel(); + super.dispose(); + } + + void _scheduleFeedback() { + _feedbackTimer ??= Timer(_feedbackDelay, () { + if (mounted && widget.controller.checking) { + setState(() => _showCheckFeedback = true); + } + }); + } + + void _handleUpdate() { + // Keep the previous result's geometry until a new check result arrives. + if (widget.controller.checking) { + _scheduleFeedback(); + } else { + _feedbackTimer?.cancel(); + _feedbackTimer = null; + _showCheckFeedback = false; + _presentation = _currentPresentation(); + } + + setState(() {}); + } + + _UpdatePresentation _currentPresentation() { + final controller = widget.controller; + + return ( + title: _statusTitle(controller), + detail: _statusDetail(controller), + action: _actionLabel(controller), + lastChecked: controller.lastChecked, + externalLink: controller.external && !controller.available && !controller.busy, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final controller = widget.controller; + final lastChecked = _presentation.lastChecked; + final compact = MediaQuery.sizeOf(context).width < Breakpoints.desktopSmall; + + final content = SingleChildScrollView( + padding: EdgeInsets.all(compact ? Spacing.md : Spacing.lg), + child: Align( + alignment: AlignmentDirectional.topStart, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: compact ? 600 : 480), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Installed version', style: theme.textTheme.bodyMedium), + const SizedBox(height: Spacing.xs), + FutureBuilder( + future: _installedVersion, + builder: (context, snapshot) => Text( + switch (snapshot) { + AsyncSnapshot(data: final PackageInfo info) => AboutLinks.versionLabel(info), + AsyncSnapshot(hasError: true) => 'Unavailable', + _ => 'Loading…', + }, + style: theme.textTheme.titleLarge, + ), + ), + const SizedBox(height: Spacing.md), + Semantics( + liveRegion: true, + // Reserve wrapped error text so a failed check never moves the action. + child: IndexedStack( + index: 0, + alignment: AlignmentDirectional.topStart, + children: [ + Text(_presentation.title, style: theme.textTheme.titleLarge), + Text(_checkFailedTitle, style: theme.textTheme.titleLarge), + ], + ), + ), + if (lastChecked != null) ...[ + const SizedBox(height: Spacing.sm), + Text( + _lastCheckedLabel(context, lastChecked), + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ], + if (_presentation.detail case final String detail) ...[ + const SizedBox(height: Spacing.sm), + Text(detail, style: theme.textTheme.bodyMedium), + ], + Padding( + padding: const EdgeInsets.symmetric(vertical: Spacing.sm), + child: SizedBox( + height: Spacing.xs, + child: controller.busy && (!controller.checking || _showCheckFeedback) + ? AppLinearProgressIndicator( + value: controller.progress, + semanticsLabel: controller.checking ? 'Checking for updates' : 'Update progress', + ) + : null, + ), + ), + if (!controller.staged) + Align( + alignment: AlignmentDirectional.centerStart, + child: FilledButton( + key: const ValueKey('check-updates'), + onPressed: controller.busy ? null : () => _performAction(context, controller), + style: controller.checking + ? FilledButton.styleFrom( + disabledBackgroundColor: theme.colorScheme.primary, + disabledForegroundColor: theme.colorScheme.onPrimary, + ) + : null, + child: IndexedStack( + index: controller.checking && _showCheckFeedback ? 1 : 0, + alignment: Alignment.center, + children: [Text(_presentation.action), const Text('Checking…')], + ), + ), + ), + if (_presentation.externalLink) + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton( + onPressed: () => _openExternalUpdate(context, controller), + child: Text(controller.channel == UpdateChannel.apt ? 'Repository setup' : 'Downloads'), + ), + ), + const SizedBox(height: Spacing.xl), + const Divider(height: 1), + const SizedBox(height: Spacing.md), + SettingsToggleRow( + label: 'Check automatically', + value: controller.automatic, + onChanged: controller.setAutomatic, + padding: const EdgeInsets.symmetric(vertical: Spacing.xs), + ), + Text( + 'Check for new versions in the background.', + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ), + ); + + return Scaffold( + appBar: compact + ? AppPageBar(context: context, title: const Text('App updates'), leading: const BackButton()) + : null, + body: SafeArea( + child: compact + ? content + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const LibraryPageHeader(title: 'App updates', leading: BackButton()), + Expanded(child: content), + ], + ), + ), + ); + } + + String _lastCheckedLabel(BuildContext context, DateTime checked) { + final local = checked.toLocal(); + final localizations = MaterialLocalizations.of(context); + final date = localizations.formatMediumDate(local); + final time = localizations.formatTimeOfDay( + TimeOfDay.fromDateTime(local), + alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), + ); + + return 'Last checked $date at $time'; + } + + String _statusTitle(UpdateController controller) => switch (controller.status) { + UpdateStatus.unchecked => controller.lastChecked == null ? 'Not checked yet' : 'Ready to check for updates', + UpdateStatus.checking => 'Checking for updates…', + UpdateStatus.upToDate => 'You’re up to date', + UpdateStatus.available => + controller.release == null + ? 'Update available' + : 'Version ${controller.release!.version} (${controller.release!.buildNumber}) is available', + UpdateStatus.installing => 'Updating Papyrus', + UpdateStatus.staged => 'Ready to finish updating', + UpdateStatus.handedOff => 'Update started', + UpdateStatus.checkFailed => _checkFailedTitle, + UpdateStatus.installFailed => 'Couldn’t finish the update', + }; + + String? _statusDetail(UpdateController controller) { + if (controller.status == UpdateStatus.checkFailed || controller.status == UpdateStatus.installFailed) { + return null; + } + + if (controller.status == UpdateStatus.installing || + controller.status == UpdateStatus.staged || + controller.status == UpdateStatus.handedOff) { + return controller.message; + } + + if (controller.checking || (!controller.available && !controller.external)) { + return null; + } + + return switch (controller.channel) { + UpdateChannel.apt => + 'Install updates through your system’s software updater. Set up the Papyrus repository if needed.', + UpdateChannel.manual => 'Install updates by downloading the latest Papyrus package.', + UpdateChannel.windows => 'The update will install after you close Papyrus normally.', + UpdateChannel.appImage => 'The update takes effect when you close and reopen Papyrus.', + UpdateChannel.web => 'Reload when you have finished reading to use the latest version.', + _ => 'Your device will guide you through installation.', + }; + } + + String _actionLabel(UpdateController controller) { + if (controller.checking) return 'Checking…'; + if (controller.busy) return 'Updating…'; + if (controller.status == UpdateStatus.checkFailed || controller.status == UpdateStatus.installFailed) { + return 'Retry'; + } + + if (controller.available && controller.status != UpdateStatus.handedOff) { + return switch (controller.channel) { + UpdateChannel.web => 'Reload', + UpdateChannel.apt => 'Repository setup', + UpdateChannel.manual => 'Downloads', + _ => 'Update', + }; + } + + return 'Check for updates'; + } + + void _performAction(BuildContext context, UpdateController controller) { + if (!controller.available || controller.status == UpdateStatus.handedOff) { + controller.check(); + return; + } + + switch (controller.channel) { + case UpdateChannel.apt: + case UpdateChannel.manual: + _openExternalUpdate(context, controller); + default: + controller.install(); + } + } + + void _openExternalUpdate(BuildContext context, UpdateController controller) { + final uri = controller.channel == UpdateChannel.apt + ? Uri.https('github.com', '/PapyrusReader/client/blob/master/docs/INSTALL.md') + : AboutLinks.releases; + + AboutLinks.open(context, uri); + } +} diff --git a/app/lib/pages/book_edit_page.dart b/app/lib/pages/book_edit_page.dart index 0c187b8a..f63e2d29 100644 --- a/app/lib/pages/book_edit_page.dart +++ b/app/lib/pages/book_edit_page.dart @@ -52,6 +52,7 @@ class _BookEditPageState extends State { // Text controllers final _titleController = TextEditingController(); final _subtitleController = TextEditingController(); + final _genreController = TextEditingController(); final _authorController = TextEditingController(); final _publisherController = TextEditingController(); final _languageController = TextEditingController(); @@ -95,6 +96,7 @@ class _BookEditPageState extends State { _titleController.text = book.title; _subtitleController.text = book.subtitle ?? ''; + _genreController.text = book.genre ?? ''; _authorController.text = book.author; _publisherController.text = book.publisher ?? ''; _languageController.text = book.language ?? ''; @@ -126,6 +128,7 @@ class _BookEditPageState extends State { void dispose() { _titleController.dispose(); _subtitleController.dispose(); + _genreController.dispose(); _authorController.dispose(); _publisherController.dispose(); _languageController.dispose(); @@ -359,6 +362,8 @@ class _BookEditPageState extends State { const SizedBox(height: Spacing.md), BookTextField(controller: _subtitleController, label: 'Subtitle', onChanged: _provider.updateSubtitle), const SizedBox(height: Spacing.md), + BookTextField(controller: _genreController, label: 'Genre', onChanged: _provider.updateGenre), + const SizedBox(height: Spacing.md), BookTextField( controller: _descriptionController, label: 'Description', @@ -766,6 +771,7 @@ class _BookEditPageState extends State { if (book != null) { _titleController.text = book.title; _subtitleController.text = book.subtitle ?? ''; + _genreController.text = book.genre ?? ''; _authorController.text = book.author; _publisherController.text = book.publisher ?? ''; _languageController.text = book.language ?? ''; diff --git a/app/lib/pages/catalogs_page.dart b/app/lib/pages/catalogs_page.dart index d200026c..46494c74 100644 --- a/app/lib/pages/catalogs_page.dart +++ b/app/lib/pages/catalogs_page.dart @@ -296,14 +296,19 @@ class _CatalogsPageState extends State { onRetry: (job) => unawaited(retryOpdsDownload(context, job)), ); - if (mobile) { - if (widget.catalogId == null) { - return LibraryMobileToolbar( - onMenuPressed: () => openAppDrawer(context, Scaffold.maybeOf(context)), - actions: [downloadsButton], - ); - } + if (widget.catalogId == null) { + return LibraryCollectionHeader( + title: 'Catalogs', + onMenuPressed: () => openAppDrawer(context, Scaffold.maybeOf(context)), + actions: [ + downloadsButton, + if (!mobile) + LibraryAddButton(label: 'Add catalog', onPressed: catalogs.scope == null ? null : () => _edit(catalogs)), + ], + ); + } + if (mobile) { return LibraryPageHeader( compact: true, key: const Key('catalog-mobile-header'), @@ -321,18 +326,6 @@ class _CatalogsPageState extends State { ); } - if (widget.catalogId == null) { - return Padding( - padding: const EdgeInsets.only(top: Spacing.lg, left: Spacing.lg, right: Spacing.lg), - child: LibraryToolbar( - actions: [ - downloadsButton, - LibraryAddButton(label: 'Add catalog', onPressed: catalogs.scope == null ? null : () => _edit(catalogs)), - ], - ), - ); - } - return LibraryPageHeader( title: catalog?.name ?? 'Catalogs', dividerKey: const Key('catalog-header-divider'), diff --git a/app/lib/pages/licenses_page.dart b/app/lib/pages/licenses_page.dart index a0ada2fb..c446607b 100644 --- a/app/lib/pages/licenses_page.dart +++ b/app/lib/pages/licenses_page.dart @@ -1,31 +1,56 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:papyrus/services/license_packages.dart'; import 'package:papyrus/themes/app_motion.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/library/library_page_header.dart'; import 'package:papyrus/widgets/shared/app_motion_control.dart'; class LicensesPage extends StatefulWidget { - const LicensesPage({super.key}); + const LicensesPage({super.key, this.initialLicenses}); + + final LicensePackages? initialLicenses; @override State createState() => _LicensesPageState(); } class _LicensesPageState extends State { - late Future>> _licenses = _loadLicenses(); + late Future _licenses = widget.initialLicenses == null + ? _loadLicenses() + : SynchronousFuture(widget.initialLicenses!); + Timer? _loadingTimer; + bool _showLoading = false; String? _selectedPackage; - Future>> _loadLicenses() async { - final packages = >{}; + @override + void dispose() { + _loadingTimer?.cancel(); + super.dispose(); + } - await for (final entry in LicenseRegistry.licenses) { - for (final package in entry.packages) { - (packages[package] ??= []).add(entry); + Future _loadLicenses() async { + Future? minimumFeedback; + _showLoading = false; + _loadingTimer = Timer(const Duration(milliseconds: 300), () { + if (mounted) { + minimumFeedback = Future.delayed(const Duration(milliseconds: 500)); + setState(() => _showLoading = true); } - } + }); - return packages; + try { + return await loadLicensePackages(); + } finally { + _loadingTimer?.cancel(); + + // Fast loads stay immediate. Once visible, feedback remains readable. + if (minimumFeedback != null) { + await minimumFeedback; + } + } } @override @@ -39,7 +64,9 @@ class _LicensesPageState extends State { if (snapshot.hasError) { return Center( child: TextButton( - onPressed: () => setState(() => _licenses = _loadLicenses()), + onPressed: () => setState(() { + _licenses = _loadLicenses(); + }), child: const Text('Could not load licenses. Retry'), ), ); @@ -48,7 +75,7 @@ class _LicensesPageState extends State { final packages = snapshot.data; if (packages == null) { - return const Center(child: Text('Loading licenses…')); + return _showLoading ? const Center(child: Text('Loading licenses…')) : const SizedBox.shrink(); } if (packages.isEmpty) { @@ -63,7 +90,7 @@ class _LicensesPageState extends State { builder: (context, constraints) { final wide = constraints.maxWidth >= Breakpoints.tablet; - final list = ListView.builder( + final listView = ListView.builder( key: const PageStorageKey('license-packages'), padding: const EdgeInsets.all(Spacing.md), itemCount: names.length, @@ -103,6 +130,13 @@ class _LicensesPageState extends State { }, ); + // ListTile ink paints on its nearest Material, outside the scroll viewport. + final list = Material( + type: MaterialType.transparency, + clipBehavior: Clip.hardEdge, + child: listView, + ); + if (!wide) { return list; } @@ -141,7 +175,6 @@ class _LicenseScaffold extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ LibraryPageHeader(title: title, leading: const BackButton(), compact: compact), - if (compact) const Divider(height: 1), Expanded(child: child), ], ), diff --git a/app/lib/pages/profile_page.dart b/app/lib/pages/profile_page.dart index 793561cd..0008bff5 100644 --- a/app/lib/pages/profile_page.dart +++ b/app/lib/pages/profile_page.dart @@ -4,39 +4,42 @@ import 'package:flutter/material.dart'; import 'package:papyrus/widgets/shared/app_page_bar.dart'; import 'package:go_router/go_router.dart'; import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; import 'package:papyrus/themes/app_motion.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/profile/about_settings_section.dart'; import 'package:papyrus/widgets/profile/accessibility_settings_section.dart'; import 'package:papyrus/widgets/profile/account_settings_section.dart'; import 'package:papyrus/widgets/profile/appearance_settings_section.dart'; -import 'package:papyrus/widgets/profile/notifications_settings_section.dart'; -import 'package:papyrus/widgets/profile/privacy_settings_section.dart'; -import 'package:papyrus/widgets/profile/profile_menu_item.dart'; import 'package:papyrus/widgets/profile/storage_sync_settings_section.dart'; import 'package:provider/provider.dart'; -enum _ProfileSection { - account, - appearance, - storageSync, - accessibility, - about, +enum ProfileSection { + account('account', 'Account', Icons.person_outline), + appearance('appearance', 'Appearance', Icons.palette_outlined), + storageSync('storage', 'Storage', Icons.cloud_outlined), + accessibility('accessibility', 'Accessibility', Icons.accessibility_outlined), + about('about', 'About', Icons.info_outline); + + const ProfileSection(this.path, this.title, this.icon); + + final String path; + final String title; + final IconData icon; } -/// User profile page with account settings, preferences, and app configuration. -/// -/// Mobile: AppBar with title, inline settings sections. -/// Desktop: Sidebar nav + content panel (similar to GitHub Settings). +/// Compact settings index with pushed detail pages, or a wide sidebar and panel. class ProfilePage extends StatefulWidget { - const ProfilePage({super.key}); + const ProfilePage({super.key, this.section}); + + final ProfileSection? section; @override State createState() => _ProfilePageState(); } class _ProfilePageState extends State { - _ProfileSection _selectedSection = _ProfileSection.account; + late ProfileSection _selectedSection = widget.section ?? ProfileSection.account; @override Widget build(BuildContext context) { @@ -47,33 +50,87 @@ class _ProfilePageState extends State { return _buildDesktopLayout(context); } + if (widget.section != null) { + return _buildMobileDetail(context); + } + return _buildMobileLayout(context); } Widget _buildMobileLayout(BuildContext context) { + final auth = context.watch(); + final preferences = context.watch(); + return Scaffold( appBar: AppPageBar(context: context, title: const Text('Settings')), body: SafeArea( child: ListView( - padding: const EdgeInsets.all(Spacing.md), + key: const PageStorageKey('settings-index'), + padding: const EdgeInsets.symmetric(vertical: Spacing.sm), children: [ - const AccountSettingsSection(isDesktop: false), - const AppearanceSettingsSection(isDesktop: false), - const NotificationsSettingsSection(isDesktop: false), - const StorageSyncSettingsSection(isDesktop: false), - StorageSyncSettingsSection.buildMobileAcquisitionSection(context), - const PrivacySettingsSection(isDesktop: false), - const AccessibilitySettingsSection(isDesktop: false), - const AboutSettingsSection(isDesktop: false), - const Divider(height: 1), - ProfileMenuItem( - icon: Icons.logout, - label: 'Log out', - isDestructive: true, - showChevron: false, + _buildMobileItem( + context, + ProfileSection.account, + subtitle: auth.isOfflineMode ? 'Offline profile' : auth.profileDisplayName, + ), + const Divider(height: Spacing.lg), + _buildMobileItem( + context, + ProfileSection.appearance, + subtitle: '${AppearanceSettingsSection.getThemeLabel(preferences.themeModePref)} theme, page layout', + ), + _buildMobileItem(context, ProfileSection.accessibility, subtitle: 'Text size, contrast, motion'), + const Divider(height: Spacing.lg), + _buildMobileItem( + context, + ProfileSection.storageSync, + subtitle: auth.isOfflineMode ? 'On-device library, backups' : 'Sync, servers, downloads', + ), + const ListTile( + enabled: false, + leading: Icon(Icons.notifications_outlined), + title: Text('Notifications'), + subtitle: Text('Not available yet'), + ), + const ListTile( + enabled: false, + leading: Icon(Icons.shield_outlined), + title: Text('Privacy'), + subtitle: Text('Not available yet'), + ), + const Divider(height: Spacing.lg), + _buildMobileItem(context, ProfileSection.about, subtitle: 'Version, support, licenses'), + const Divider(height: Spacing.lg), + ListTile( + leading: Icon(Icons.logout, color: Theme.of(context).colorScheme.error), + title: Text('Log out', style: TextStyle(color: Theme.of(context).colorScheme.error)), onTap: () => _showLogoutConfirmation(context), ), - const SizedBox(height: Spacing.lg), + ], + ), + ), + ); + } + + Widget _buildMobileItem(BuildContext context, ProfileSection section, {required String subtitle}) { + return ListTile( + leading: Icon(section.icon), + title: Text(section.title), + subtitle: Text(subtitle), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('/profile/${section.path}'), + ); + } + + Widget _buildMobileDetail(BuildContext context) { + return Scaffold( + appBar: AppPageBar(context: context, title: Text(_selectedSection.title), leading: const BackButton()), + body: SafeArea( + child: ListView( + key: PageStorageKey('settings-${_selectedSection.path}'), + padding: const EdgeInsets.fromLTRB(Spacing.md, Spacing.sm, Spacing.md, Spacing.lg), + children: [ + _buildSectionContent(context, isDesktop: false), ], ), ), @@ -112,13 +169,13 @@ class _ProfilePageState extends State { context, icon: Icons.person_outline, label: 'Account', - section: _ProfileSection.account, + section: ProfileSection.account, ), _buildNavItem( context, icon: Icons.palette_outlined, label: 'Appearance', - section: _ProfileSection.appearance, + section: ProfileSection.appearance, ), _buildNavItem( context, @@ -130,7 +187,7 @@ class _ProfilePageState extends State { context, icon: Icons.cloud_outlined, label: 'Storage', - section: _ProfileSection.storageSync, + section: ProfileSection.storageSync, ), _buildNavItem( context, @@ -142,9 +199,9 @@ class _ProfilePageState extends State { context, icon: Icons.accessibility_outlined, label: 'Accessibility', - section: _ProfileSection.accessibility, + section: ProfileSection.accessibility, ), - _buildNavItem(context, icon: Icons.info_outline, label: 'About', section: _ProfileSection.about), + _buildNavItem(context, icon: Icons.info_outline, label: 'About', section: ProfileSection.about), const SizedBox(height: Spacing.sm), Divider(height: 1, color: colorScheme.outlineVariant), const SizedBox(height: Spacing.sm), @@ -169,7 +226,7 @@ class _ProfilePageState extends State { BuildContext context, { required IconData icon, required String label, - _ProfileSection? section, + ProfileSection? section, bool enabled = true, bool isDestructive = false, VoidCallback? onTap, @@ -232,7 +289,7 @@ class _ProfilePageState extends State { final textTheme = Theme.of(context).textTheme; return SingleChildScrollView( - padding: const EdgeInsets.all(Spacing.xl), + padding: const EdgeInsets.fromLTRB(Spacing.lg, Spacing.md, Spacing.lg, Spacing.lg), child: Align( alignment: Alignment.topLeft, child: ConstrainedBox( @@ -250,34 +307,16 @@ class _ProfilePageState extends State { ); } - String get _sectionTitle { - switch (_selectedSection) { - case _ProfileSection.account: - return 'Account'; - case _ProfileSection.appearance: - return 'Appearance'; - case _ProfileSection.storageSync: - return 'Storage'; - case _ProfileSection.accessibility: - return 'Accessibility'; - case _ProfileSection.about: - return 'About'; - } - } + String get _sectionTitle => _selectedSection.title; - Widget _buildSectionContent(BuildContext context) { - switch (_selectedSection) { - case _ProfileSection.account: - return const AccountSettingsSection(isDesktop: true); - case _ProfileSection.appearance: - return const AppearanceSettingsSection(isDesktop: true); - case _ProfileSection.storageSync: - return const StorageSyncSettingsSection(isDesktop: true); - case _ProfileSection.accessibility: - return const AccessibilitySettingsSection(isDesktop: true); - case _ProfileSection.about: - return const AboutSettingsSection(isDesktop: true); - } + Widget _buildSectionContent(BuildContext context, {bool isDesktop = true}) { + return switch (_selectedSection) { + ProfileSection.account => AccountSettingsSection(isDesktop: isDesktop, showHeader: false), + ProfileSection.appearance => AppearanceSettingsSection(isDesktop: isDesktop, showHeader: false), + ProfileSection.storageSync => StorageSyncSettingsSection(isDesktop: isDesktop, showHeader: false), + ProfileSection.accessibility => AccessibilitySettingsSection(isDesktop: isDesktop, showHeader: false), + ProfileSection.about => AboutSettingsSection(isDesktop: isDesktop, showHeader: false), + }; } Future _handleLogout(BuildContext context) async { 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/pages/statistics_page.dart b/app/lib/pages/statistics_page.dart index ee49112e..ef3c645b 100644 --- a/app/lib/pages/statistics_page.dart +++ b/app/lib/pages/statistics_page.dart @@ -1,41 +1,35 @@ import 'package:flutter/material.dart'; -import 'package:papyrus/widgets/shared/empty_state.dart'; +import 'package:intl/intl.dart'; import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/providers/statistics_provider.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:papyrus/widgets/library/library_page_header.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/shared/app_date_picker.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; +import 'package:papyrus/widgets/shared/empty_state.dart'; import 'package:papyrus/widgets/statistics/reading_charts.dart'; import 'package:papyrus/widgets/statistics/stat_card.dart'; +import 'package:papyrus/widgets/statistics/statistics_row.dart'; import 'package:provider/provider.dart'; -import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; -import 'package:papyrus/widgets/shared/app_date_picker.dart'; -/// Statistics page displaying reading analytics and charts. -/// -/// Provides two responsive layouts: -/// - **Mobile**: Vertical scrolling with stacked charts -/// - **Desktop**: Full-width charts with period segmented control class StatisticsPage extends StatefulWidget { - const StatisticsPage({super.key}); + const StatisticsPage({super.key, this.now}); + + final DateTime Function()? now; @override State createState() => _StatisticsPageState(); } class _StatisticsPageState extends State { - late StatisticsProvider _provider; - - @override - void initState() { - super.initState(); - _provider = StatisticsProvider(); - } + late final _provider = StatisticsProvider(now: widget.now); + bool _showPages = false; @override void didChangeDependencies() { super.didChangeDependencies(); - // Connect to DataStore for persistent storage - final dataStore = context.read(); - _provider.attach(dataStore); + _provider.attach(context.watch()); } @override @@ -46,525 +40,431 @@ class _StatisticsPageState extends State { @override Widget build(BuildContext context) { - return ChangeNotifierProvider.value( - value: _provider, - child: Consumer( - builder: (context, provider, _) { - final screenWidth = MediaQuery.of(context).size.width; - final isDesktop = screenWidth >= Breakpoints.desktopSmall; - - if (provider.isLoading) { - return _buildLoadingState(context); - } - - if (isDesktop) { - return _buildDesktopLayout(context, provider); - } - - return _buildMobileLayout(context, provider); - }, + final centered = context.select((preferences) => preferences.pageAlignment == 'center'); + + return ListenableBuilder( + listenable: _provider, + builder: (context, child) => Scaffold( + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final wide = constraints.maxWidth >= Breakpoints.desktopSmall; + final padding = libraryPageHorizontalPadding(context); + + return RefreshIndicator( + onRefresh: _provider.refresh, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: EdgeInsets.all(padding), + child: Align( + alignment: centered ? Alignment.topCenter : Alignment.topLeft, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: centered ? 1120 : double.infinity), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _periodControls(context), + const SizedBox(height: Spacing.lg), + if (_provider.isLoading) const AppLinearProgressIndicator(), + if (_provider.error != null) + Padding( + padding: const EdgeInsets.only(bottom: Spacing.md), + child: Text( + _provider.error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + _overview(context), + const SizedBox(height: Spacing.xl), + _columns(context, _activity(context, wide), _sessions(context)), + const SizedBox(height: Spacing.xl), + _columns(context, _consistency(context), _genres(context)), + if (_provider.selectedPeriod == StatsPeriod.year || + _provider.selectedPeriod == StatsPeriod.allTime) ...[ + const SizedBox(height: Spacing.xl), + StatSectionCard( + title: 'Books finished by month', + subtitle: _periodTitle, + child: _provider.monthlyStats.every((month) => month.booksRead == 0) + ? const EmptyState.compact( + icon: Icons.auto_stories_outlined, + title: 'No books finished in this range', + ) + : SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SizedBox( + width: (_provider.monthlyStats.length * 64.0).clamp(280, 100000), + child: BooksPerMonthChart( + monthlyStats: _provider.monthlyStats, + isDesktop: wide, + ), + ), + ), + ), + ], + ], + ), + ), + ), + ), + ); + }, + ), + ), ), ); } - Widget _buildLoadingState(BuildContext context) { - return const Scaffold(body: Center(child: AppCircularProgressIndicator())); - } - - Widget _buildMobileLayout(BuildContext context, StatisticsProvider provider) { - return Scaffold( - body: SafeArea( - child: RefreshIndicator( - onRefresh: provider.refresh, - child: ListView( - padding: const EdgeInsets.all(Spacing.md), - children: [ - // Period filter - _buildPeriodSegmentedButton(context, provider), - const SizedBox(height: Spacing.md), - // Custom range indicator - if (provider.hasCustomRange) ...[ - _buildCustomRangeChip(context, provider), - const SizedBox(height: Spacing.md), - ], - // Summary cards - _buildMobileSummaryCards(context, provider), - const SizedBox(height: Spacing.lg), - // Reading time chart - StatSectionCard( - title: 'Reading time', - child: ReadingTimeBarChart( - activities: provider.readingTimeData, - isWeekly: provider.selectedPeriod == StatsPeriod.week, - ), - ), - const SizedBox(height: Spacing.lg), - // Pages read trend - StatSectionCard( - title: 'Pages read', - child: PagesReadLineChart( - activities: provider.pagesReadData, - isWeekly: provider.selectedPeriod == StatsPeriod.week, + Widget _periodControls(BuildContext context) => LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < Breakpoints.tablet; + final controls = Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final period in StatsPeriod.values.where((period) => period != StatsPeriod.custom)) + Semantics( + selected: _provider.selectedPeriod == period, + child: TextButton( + key: ValueKey('statistics-period-${period.name}'), + style: TextButton.styleFrom( + minimumSize: const Size(48, 48), + padding: EdgeInsets.symmetric(horizontal: compact ? Spacing.sm : Spacing.md), + textStyle: Theme.of(context).textTheme.labelLarge, + foregroundColor: _provider.selectedPeriod == period + ? Theme.of(context).colorScheme.onPrimaryContainer + : Theme.of(context).colorScheme.primary, + backgroundColor: _provider.selectedPeriod == period + ? Theme.of(context).colorScheme.primaryContainer + : Colors.transparent, ), + onPressed: () => _provider.setPeriod(period), + child: Text(_periodLabel(period)), ), - const SizedBox(height: Spacing.lg), - // Books per month (for year/all time views) - if (provider.selectedPeriod == StatsPeriod.year || provider.selectedPeriod == StatsPeriod.allTime) ...[ - StatSectionCard( - title: 'Books per month', - child: BooksPerMonthChart(monthlyStats: provider.monthlyStats), - ), - const SizedBox(height: Spacing.lg), - ], - // Genre distribution - _buildGenreDistributionCard(context, provider), - const SizedBox(height: Spacing.lg), - // Reading insights (session stats + streaks) - _buildInsightsCard(context, provider), - ], + ), + ], + ); + + return Row( + children: [ + Expanded( + child: SingleChildScrollView(scrollDirection: Axis.horizontal, child: controls), ), - ), - ), - ); - } + const SizedBox(width: Spacing.sm), + Semantics( + selected: _provider.hasCustomRange, + child: compact + ? IconButton.outlined( + onPressed: _pickDates, + tooltip: 'Custom range', + isSelected: _provider.hasCustomRange, + icon: const Icon(Icons.date_range_outlined), + ) + : OutlinedButton.icon( + style: OutlinedButton.styleFrom(minimumSize: const Size(48, 48)), + onPressed: _pickDates, + icon: const Icon(Icons.date_range_outlined, size: 18), + label: const Text('Custom range'), + ), + ), + ], + ); + }, + ); - Widget _buildPeriodSegmentedButton(BuildContext context, StatisticsProvider provider) { - final colorScheme = Theme.of(context).colorScheme; + Widget _overview(BuildContext context) { final textTheme = Theme.of(context).textTheme; - final periods = StatsPeriod.values.where((item) => item != StatsPeriod.custom).toList(); - final selectedPeriod = provider.selectedPeriod == StatsPeriod.custom ? StatsPeriod.week : provider.selectedPeriod; + final colors = Theme.of(context).colorScheme; - return Row( + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(AppRadius.lg), - border: Border.all(color: colorScheme.outlineVariant, width: BorderWidths.thin), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(AppRadius.lg), - child: IntrinsicHeight( - child: Row( - children: [ - for (int i = 0; i < periods.length; i++) ...[ - Expanded( - child: GestureDetector( - onTap: () => provider.setPeriod(periods[i]), - child: Container( - padding: const EdgeInsets.symmetric(vertical: Spacing.sm), - color: selectedPeriod == periods[i] - ? colorScheme.primaryContainer - : colorScheme.surfaceContainerLow, - child: Center( - child: Text( - _getPeriodLabel(periods[i]), - style: textTheme.labelLarge?.copyWith( - color: selectedPeriod == periods[i] - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - ), - ), - ), - ), + Text(_periodTitle, style: textTheme.headlineSmall), + const SizedBox(height: Spacing.sm), + Text('Your reading at a glance', style: textTheme.bodyMedium?.copyWith(color: colors.onSurfaceVariant)), + const SizedBox(height: Spacing.lg), + LayoutBuilder( + builder: (context, constraints) { + final minimumWidth = MediaQuery.textScalerOf(context).scale(140); + final columns = (constraints.maxWidth / minimumWidth).floor().clamp(1, 4); + final width = (constraints.maxWidth - Spacing.md * (columns - 1)) / columns; + + return Wrap( + spacing: Spacing.md, + runSpacing: Spacing.lg, + children: [ + for (final metric in [ + (_provider.totalReadingLabel, 'Reading time'), + ('${_provider.pagesRead}', _provider.hasEstimatedPages ? 'Pages read (estimated)' : 'Pages read'), + ('${_provider.totalBooks}', 'Books finished'), + ('${_provider.daysRead}', 'Days read'), + ]) + SizedBox( + width: width, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + metric.$1, + style: textTheme.headlineMedium?.copyWith(fontFeatures: const [FontFeature.tabularFigures()]), ), - ), - if (i < periods.length - 1) - VerticalDivider(width: 1, thickness: 1, color: colorScheme.outlineVariant), - ], - ], - ), - ), - ), - ), - ), - const SizedBox(width: Spacing.sm), - IconButton.outlined( - onPressed: () => _showDateRangePicker(context, provider), - icon: const Icon(Icons.date_range_outlined, size: 20), - tooltip: 'Custom date range', - style: IconButton.styleFrom(side: BorderSide(color: colorScheme.outlineVariant)), + const SizedBox(height: Spacing.xs), + if (metric.$2 == 'Days read') + Tooltip( + message: 'Days with at least 5 minutes of reading', + child: Text( + metric.$2, + style: textTheme.bodySmall?.copyWith(color: colors.onSurfaceVariant), + ), + ) + else + Text(metric.$2, style: textTheme.bodySmall?.copyWith(color: colors.onSurfaceVariant)), + ], + ), + ), + ], + ); + }, ), ], ); } - Widget _buildCustomRangeChip(BuildContext context, StatisticsProvider provider) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; + Widget _columns(BuildContext context, Widget main, Widget aside) => LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 800 || MediaQuery.textScalerOf(context).scale(16) > 24) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + main, + const SizedBox(height: Spacing.lg), + aside, + ], + ); + } - return Container( - padding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm), - decoration: BoxDecoration( - color: colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(AppRadius.full), - ), - child: Row( - mainAxisSize: MainAxisSize.min, + return StatisticsRow(main: main, aside: aside); + }, + ); + + String get _chartRange { + final activities = _provider.readingTimeData; + + if (activities.isEmpty) { + return _provider.periodLabel; + } + + final dates = + '${DateFormat.yMMMd().format(activities.first.date)} – ' + '${DateFormat.yMMMd().format(activities.last.endDate.subtract(const Duration(microseconds: 1)))}'; + if (activities.length == 1 && _provider.bucketSize == StatisticsBucketSize.day) { + return DateFormat.yMMMd().format(activities.first.date); + } + + return dates; + } + + Widget _activity(BuildContext context, bool wide) { + final activities = _provider.readingTimeData; + final hasData = activities.any((activity) => _showPages ? activity.pages > 0 : activity.seconds > 0); + + return StatSectionCard( + title: 'Reading activity', + subtitle: _chartRange, + action: Wrap( + spacing: Spacing.xs, + alignment: WrapAlignment.end, children: [ - Icon(Icons.date_range, size: 16, color: colorScheme.onSecondaryContainer), - const SizedBox(width: Spacing.sm), - Text(provider.periodLabel, style: textTheme.labelMedium?.copyWith(color: colorScheme.onSecondaryContainer)), - const SizedBox(width: Spacing.sm), - GestureDetector( - onTap: () => provider.setPeriod(StatsPeriod.week), - child: Icon(Icons.close, size: 16, color: colorScheme.onSecondaryContainer), + ChoiceChip( + label: const Text('Time'), + padding: const EdgeInsets.symmetric(horizontal: Spacing.xs), + labelPadding: const EdgeInsets.symmetric(horizontal: Spacing.xs), + showCheckmark: false, + selected: !_showPages, + onSelected: (_) => setState(() => _showPages = false), + ), + ChoiceChip( + label: const Text('Pages'), + padding: const EdgeInsets.symmetric(horizontal: Spacing.xs), + labelPadding: const EdgeInsets.symmetric(horizontal: Spacing.xs), + showCheckmark: false, + selected: _showPages, + onSelected: (_) => setState(() => _showPages = true), ), ], ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!hasData) + EmptyState.compact( + icon: Icons.auto_stories_outlined, + title: _showPages ? 'No pages recorded in this range' : 'No reading time in this range', + ) + else + ReadingActivityBarChart( + activities: activities, + metric: _showPages ? ReadingActivityMetric.pages : ReadingActivityMetric.time, + isDesktop: wide, + ), + ], + ), ); } - Widget _buildMobileSummaryCards(BuildContext context, StatisticsProvider provider) { - return Row( - children: [ - Expanded( - child: CompactStatCard(value: provider.totalBooks.toString(), label: 'Books'), - ), - const SizedBox(width: Spacing.sm), - Expanded( - child: CompactStatCard(value: provider.pagesRead.toString(), label: 'Pages'), - ), - const SizedBox(width: Spacing.sm), - Expanded( - child: CompactStatCard(value: provider.totalReadingLabel, label: 'Reading time'), - ), - const SizedBox(width: Spacing.sm), - Expanded( - child: CompactStatCard(value: provider.goalsCompleted.toString(), label: 'Goals'), - ), - ], - ); - } - - Widget _buildInsightsCard(BuildContext context, StatisticsProvider provider, {bool isDesktop = false}) { - final colorScheme = Theme.of(context).colorScheme; - final sessionStats = provider.sessionStats; - final streak = provider.streak; - final rowGap = SizedBox(height: isDesktop ? Spacing.md : Spacing.sm); + Widget _sessions(BuildContext context) { + final sessions = _provider.sessionStats; return StatSectionCard( - title: 'Reading insights', - isDesktop: isDesktop, + title: 'Your reading rhythm', + subtitle: _periodTitle, child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _buildStatRow( - context, - icon: Icons.timer_outlined, - label: 'Average session', - value: sessionStats.averageSessionLabel, - ), - rowGap, - _buildStatRow( - context, - icon: Icons.speed_outlined, - label: 'Reading velocity', - value: sessionStats.velocityLabel, - ), - rowGap, - _buildStatRow( - context, - icon: Icons.repeat_outlined, - label: 'Total sessions', - value: '${sessionStats.totalSessions}', - ), - rowGap, - _buildStatRow( - context, - icon: Icons.show_chart_outlined, - label: 'Avg. daily reading', - value: provider.averageReadingLabel, - ), - Divider(height: isDesktop ? Spacing.xl : Spacing.lg, color: colorScheme.outlineVariant), - _buildStreakRow( - context, - icon: Icons.local_fire_department, - iconColor: streak.hasActiveStreak ? colorScheme.tertiary : colorScheme.onSurfaceVariant, - label: 'Current streak', - value: '${streak.currentStreak} days', - isHighlighted: streak.isCurrentBest, - ), - rowGap, - _buildStreakRow( - context, - icon: Icons.emoji_events_outlined, - iconColor: colorScheme.primary, - label: 'Best streak', - value: '${streak.bestStreak} days', - ), - rowGap, - _buildStreakRow( - context, - icon: Icons.calendar_month_outlined, - iconColor: colorScheme.onSurfaceVariant, - label: 'Days this month', - value: '${streak.daysThisMonth} of ${streak.totalDaysInMonth}', + _detail(context, 'Sessions', '${sessions.totalSessions}'), + _detail(context, 'Average session', sessions.totalSessions == 0 ? '—' : sessions.averageSessionLabel), + _detail(context, 'Books read', '${_provider.booksRead}'), + Tooltip( + message: 'Reading time per elapsed calendar day in the selected period, including days without reading', + child: _detail(context, 'Daily average', _provider.averageReadingLabel), ), ], ), ); } - Widget _buildStatRow(BuildContext context, {required IconData icon, required String label, required String value}) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; + Widget _consistency(BuildContext context) { + final streak = _provider.streak; + String days(int count) => '$count ${count == 1 ? 'day' : 'days'}'; - return Row( - children: [ - Icon(icon, size: 20, color: colorScheme.onSurfaceVariant), - const SizedBox(width: Spacing.sm), - Text(label, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - const Spacer(), - Text(value, style: textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600)), - ], + return StatSectionCard( + title: 'Reading consistency', + subtitle: 'Lifetime streaks', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _detail(context, 'Current streak', days(streak.currentStreak)), + _detail(context, 'Best streak', days(streak.bestStreak)), + ], + ), ); } - Widget _buildGenreDistributionCard(BuildContext context, StatisticsProvider provider) { - return _buildGenreBarsCard(context, provider); - } - - Widget _buildGenreBarsCard(BuildContext context, StatisticsProvider provider, {bool isDesktop = false}) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - final genres = provider.genreDistribution; - - if (genres.isEmpty) { - return StatSectionCard( - title: 'Books by genre', - isDesktop: isDesktop, - child: const EmptyState.compact(icon: Icons.category_outlined, title: 'No genre data available'), - ); - } - - final chartColors = [ - colorScheme.primary, - colorScheme.primary.withValues(alpha: 0.7), - colorScheme.tertiary, - colorScheme.tertiary.withValues(alpha: 0.7), - colorScheme.secondary, - colorScheme.secondary.withValues(alpha: 0.7), - ]; + Widget _genres(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final genres = [..._provider.genreDistribution]..sort((left, right) => right.bookCount.compareTo(left.bookCount)); return StatSectionCard( - title: 'Books by genre', - isDesktop: isDesktop, - child: Column( - children: genres.asMap().entries.map((entry) { - final genre = entry.value; - final color = chartColors[entry.key % chartColors.length]; - final pct = (genre.percentage * 100).round(); - - return Padding( - padding: EdgeInsets.only(bottom: entry.key < genres.length - 1 ? Spacing.md : 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + title: 'Reading by genre', + subtitle: switch (_provider.selectedPeriod) { + StatsPeriod.week => 'Books read this week', + StatsPeriod.month => 'Books read this month', + StatsPeriod.year => 'Books read this year', + StatsPeriod.allTime => 'All books read', + StatsPeriod.custom => 'Books read in this range', + }, + child: genres.isEmpty + ? const EmptyState.compact( + icon: Icons.category_outlined, + title: 'No genre data in this range', + ) + : Column( children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(genre.genre, style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)), - Text('$pct%', style: textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600)), - ], - ), - const SizedBox(height: 4), - ClipRRect( - borderRadius: BorderRadius.circular(AppRadius.sm), - child: AppLinearProgressIndicator( - value: genre.percentage.clamp(0.0, 1.0), - minHeight: 6, - backgroundColor: colorScheme.surfaceContainerHighest, - color: color, + for (final genre in genres) + Padding( + padding: const EdgeInsets.only(bottom: Spacing.sm), + child: Semantics( + label: '${genre.genre}, ${genre.bookCount} books, ${(genre.percentage * 100).round()} percent', + child: ExcludeSemantics( + child: Column( + children: [ + _detail(context, genre.genre, '${(genre.percentage * 100).round()}%'), + AppLinearProgressIndicator( + value: genre.percentage.clamp(0, 1), + minHeight: 4, + color: colors.primary, + backgroundColor: colors.surfaceContainerHighest, + ), + ], + ), + ), + ), ), - ), ], ), - ); - }).toList(), - ), ); } - Widget _buildStreakRow( - BuildContext context, { - required IconData icon, - required Color iconColor, - required String label, - required String value, - bool isHighlighted = false, - }) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - - return Row( - children: [ - Icon(icon, size: 20, color: iconColor), - const SizedBox(width: Spacing.sm), - Text(label, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - const Spacer(), - Text( + Widget _detail(BuildContext context, String label, String value) => Padding( + padding: const EdgeInsets.symmetric(vertical: Spacing.sm), + child: LayoutBuilder( + builder: (context, constraints) { + final textTheme = Theme.of(context).textTheme; + final labelWidget = Text( + label, + style: textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ); + final valueWidget = Text( value, style: textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, - color: isHighlighted ? colorScheme.tertiary : null, + fontFeatures: const [FontFeature.tabularFigures()], ), - ), - ], - ); - } + ); - Widget _buildDesktopLayout(BuildContext context, StatisticsProvider provider) { - return Scaffold( - body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.all(Spacing.xl), - child: Column( + if (constraints.maxWidth < MediaQuery.textScalerOf(context).scale(240)) { + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Period toggle - _buildPeriodSegmentedButton(context, provider), - // Custom range indicator - if (provider.hasCustomRange) ...[ - const SizedBox(height: Spacing.md), - _buildCustomRangeChip(context, provider), - ], - const SizedBox(height: Spacing.lg), - // Summary cards row - _buildDesktopSummaryRow(context, provider), - const SizedBox(height: Spacing.lg), - // Charts row: Reading time + Pages read - IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: StatSectionCard( - title: 'Reading time', - isDesktop: true, - child: ReadingTimeBarChart( - activities: provider.readingTimeData, - isWeekly: provider.selectedPeriod == StatsPeriod.week, - isDesktop: true, - ), - ), - ), - const SizedBox(width: Spacing.lg), - Expanded( - child: StatSectionCard( - title: 'Pages read', - isDesktop: true, - child: PagesReadLineChart( - activities: provider.pagesReadData, - isWeekly: provider.selectedPeriod == StatsPeriod.week, - isDesktop: true, - ), - ), - ), - ], - ), - ), - const SizedBox(height: Spacing.lg), - // Second row: Genre + Books per month (if applicable) - if (provider.selectedPeriod == StatsPeriod.year || provider.selectedPeriod == StatsPeriod.allTime) - IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded(child: _buildDesktopGenreDistributionCard(context, provider)), - const SizedBox(width: Spacing.lg), - Expanded( - child: StatSectionCard( - title: 'Books per month', - isDesktop: true, - child: BooksPerMonthChart(monthlyStats: provider.monthlyStats, isDesktop: true), - ), - ), - ], - ), - ) - else - _buildDesktopGenreDistributionCard(context, provider), - const SizedBox(height: Spacing.lg), - // Reading insights (session stats + streaks) - _buildInsightsCard(context, provider, isDesktop: true), + labelWidget, + const SizedBox(height: Spacing.xs), + valueWidget, ], - ), - ), - ), - ); - } - - Widget _buildDesktopSummaryRow(BuildContext context, StatisticsProvider provider) { - return Row( - children: [ - Expanded( - child: CompactStatCard(value: provider.totalBooks.toString(), label: 'Books', isDesktop: true), - ), - const SizedBox(width: Spacing.md), - Expanded( - child: CompactStatCard(value: provider.pagesRead.toString(), label: 'Pages', isDesktop: true), - ), - const SizedBox(width: Spacing.md), - Expanded( - child: CompactStatCard(value: provider.totalReadingLabel, label: 'Reading time', isDesktop: true), - ), - const SizedBox(width: Spacing.md), - Expanded( - child: CompactStatCard(value: provider.goalsCompleted.toString(), label: 'Goals', isDesktop: true), - ), - ], - ); - } + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: labelWidget), + const SizedBox(width: Spacing.md), + Expanded( + child: Align(alignment: AlignmentDirectional.centerEnd, child: valueWidget), + ), + ], + ); + }, + ), + ); - Widget _buildDesktopGenreDistributionCard(BuildContext context, StatisticsProvider provider) { - return _buildGenreBarsCard(context, provider, isDesktop: true); - } + String get _periodTitle => _provider.hasCustomRange ? 'Custom range' : _provider.periodLabel; - String _getPeriodLabel(StatsPeriod period) { - switch (period) { - case StatsPeriod.week: - return 'Week'; - case StatsPeriod.month: - return 'Month'; - case StatsPeriod.year: - return 'Year'; - case StatsPeriod.allTime: - return 'All time'; - case StatsPeriod.custom: - return 'Custom'; - } - } + String _periodLabel(StatsPeriod period) => switch (period) { + StatsPeriod.week => 'Week', + StatsPeriod.month => 'Month', + StatsPeriod.year => 'Year', + StatsPeriod.allTime => 'All time', + StatsPeriod.custom => 'Custom range', + }; - Future _showDateRangePicker(BuildContext context, StatisticsProvider provider) async { + Future _pickDates() async { final now = DateTime.now(); - final initialRange = provider.hasCustomRange - ? DateTimeRange(start: provider.customStartDate!, end: provider.customEndDate!) - : DateTimeRange(start: now.subtract(const Duration(days: 30)), end: now); - final picked = await showAppDateRangePicker( context: context, useRootNavigator: false, - initialDateRange: initialRange, - firstDate: DateTime(2020), + initialDateRange: DateTimeRange( + start: _provider.customStartDate ?? now.subtract(const Duration(days: 30)), + end: _provider.customEndDate ?? now, + ), + firstDate: DateTime(2000), lastDate: now, helpText: 'Select date range', - cancelText: 'Cancel', confirmText: 'Apply', saveText: 'Apply', - initialEntryMode: DatePickerEntryMode.calendarOnly, - builder: (context, child) { - return Dialog( - insetPadding: const EdgeInsets.symmetric(horizontal: Spacing.xl, vertical: Spacing.xl), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.xl)), - clipBehavior: Clip.antiAlias, - child: ConstrainedBox(constraints: const BoxConstraints(maxWidth: 400, maxHeight: 500), child: child), - ); - }, ); - if (picked != null) { - provider.setCustomDateRange(picked.start, picked.end); + if (picked != null && mounted) { + _provider.setCustomDateRange(picked.start, picked.end); } } } diff --git a/app/lib/pages/support_page.dart b/app/lib/pages/support_page.dart index e00e410d..960109fa 100644 --- a/app/lib/pages/support_page.dart +++ b/app/lib/pages/support_page.dart @@ -1,5 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; import 'package:papyrus/services/about_links.dart'; import 'package:papyrus/services/app_info.dart'; import 'package:papyrus/services/support_api_client.dart'; @@ -146,6 +148,7 @@ class _SupportPageState extends State { final issue = _issue; final textTheme = Theme.of(context).textTheme; final compact = MediaQuery.sizeOf(context).width < Breakpoints.desktopSmall; + final centered = context.select((preferences) => preferences.pageAlignment == 'center'); return Scaffold( body: SafeArea( @@ -153,13 +156,17 @@ class _SupportPageState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ LibraryPageHeader(title: 'Support', leading: const BackButton(), compact: compact), - if (compact) const Divider(height: 1), Expanded( child: SingleChildScrollView( controller: _scrollController, - padding: const EdgeInsets.all(Spacing.lg), + padding: EdgeInsets.fromLTRB( + Spacing.lg, + compact ? Spacing.sm : Spacing.lg, + Spacing.lg, + Spacing.lg, + ), child: Align( - alignment: Alignment.topCenter, + alignment: centered ? Alignment.topCenter : AlignmentDirectional.topStart, child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 640), child: issue != null diff --git a/app/lib/pages/watched_folders_page.dart b/app/lib/pages/watched_folders_page.dart index 14ff4416..8fc78f6d 100644 --- a/app/lib/pages/watched_folders_page.dart +++ b/app/lib/pages/watched_folders_page.dart @@ -103,22 +103,12 @@ class _WatchedFoldersPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (desktop) - Padding( - padding: EdgeInsets.fromLTRB(padding, Spacing.lg, padding, Spacing.md), - child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: TouchTargets.desktopRecommended), - child: LibraryToolbar( - search: Text('Folders', style: Theme.of(context).textTheme.headlineSmall), - actions: [if (!empty) LibraryAddButton(label: label, onPressed: onConnect)], - ), - ), - ) - else - LibraryMobileToolbar( - search: Text('Folders', style: Theme.of(context).textTheme.titleLarge), - onMenuPressed: () => openAppDrawer(context, Scaffold.maybeOf(context)), - ), + LibraryCollectionHeader( + title: 'Folders', + onMenuPressed: () => openAppDrawer(context, Scaffold.maybeOf(context)), + actions: [if (desktop && !empty) LibraryAddButton(label: label, onPressed: onConnect)], + ), + if (desktop) const SizedBox(height: Spacing.md), if (error != null) Padding( padding: EdgeInsets.symmetric(horizontal: padding, vertical: Spacing.sm), 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/powersync/storage_sync_controller.dart b/app/lib/powersync/storage_sync_controller.dart index d2a8a49b..691a05fd 100644 --- a/app/lib/powersync/storage_sync_controller.dart +++ b/app/lib/powersync/storage_sync_controller.dart @@ -84,6 +84,18 @@ class StorageSyncController { return syncSettings.fileStorageLabel(usedBytes: fileStorageUsedBytes); } + double? get fileStorageFraction { + final usage = isAuthenticated ? mediaStorageUsage : null; + final usedBytes = usage?.usedBytes ?? fileStorageUsedBytes; + final quotaBytes = usage?.quotaBytes ?? syncSettings.fileStorageQuotaBytes; + + if (quotaBytes == null || quotaBytes <= 0) { + return null; + } + + return (usedBytes / quotaBytes).clamp(0.0, 1.0); + } + String get statusLabel { if (isGuest) { return 'Guest local'; diff --git a/app/lib/providers/book_edit_provider.dart b/app/lib/providers/book_edit_provider.dart index abfc810a..c9d560a8 100644 --- a/app/lib/providers/book_edit_provider.dart +++ b/app/lib/providers/book_edit_provider.dart @@ -176,6 +176,26 @@ class BookEditProvider extends ChangeNotifier { notifyListeners(); } + void updateGenre(String value) { + final book = _editedBook; + + if (book == null) { + return; + } + + final metadata = Map.from(book.customMetadata ?? {}); + final genre = value.trim(); + + if (genre.isEmpty) { + metadata.remove('genre'); + } else { + metadata['genre'] = genre; + } + + _editedBook = book.copyWith(customMetadata: metadata); + notifyListeners(); + } + void updatePublisher(String? value) { if (_editedBook == null) { return; 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/preferences_provider.dart b/app/lib/providers/preferences_provider.dart index 2fad0e64..a134bb23 100644 --- a/app/lib/providers/preferences_provider.dart +++ b/app/lib/providers/preferences_provider.dart @@ -16,6 +16,7 @@ class PreferencesProvider extends ChangeNotifier { // Theme static const _keyThemeMode = 'theme_mode'; + static const _keyPageAlignment = 'page_alignment'; // Reading static const _keyDefaultFont = 'default_font'; @@ -86,6 +87,20 @@ class PreferencesProvider extends ChangeNotifier { notifyListeners(); } + /// Alignment of pages with constrained content widths. + String get pageAlignment => _prefs.get(_keyPageAlignment) == 'center' ? 'center' : 'left'; + + set pageAlignment(String value) { + final alignment = value == 'center' ? 'center' : 'left'; + + if (pageAlignment == alignment) { + return; + } + + _prefs.setString(_keyPageAlignment, alignment); + notifyListeners(); + } + // -- Reading -------------------------------------------------------------- String get defaultFont => _prefs.getString(_keyDefaultFont) ?? 'Georgia'; diff --git a/app/lib/providers/reading_timer_provider.dart b/app/lib/providers/reading_timer_provider.dart new file mode 100644 index 00000000..0c150b11 --- /dev/null +++ b/app/lib/providers/reading_timer_provider.dart @@ -0,0 +1,287 @@ +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 _loaded = 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 && _loaded && !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; + _loaded = false; + 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; + _loaded = true; + loading = false; + error = null; + notifyListeners(); + } + }, + onError: (Object failure) { + if (_isCurrent(generation, repository)) { + _loaded = false; + 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; + _loaded = true; + } + } catch (_) { + if (_isCurrent(generation, repository)) { + _loaded = false; + 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 { + if (!available || busy) { + return false; + } + + 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/providers/statistics_provider.dart b/app/lib/providers/statistics_provider.dart index 6acf3498..2099673f 100644 --- a/app/lib/providers/statistics_provider.dart +++ b/app/lib/providers/statistics_provider.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:intl/intl.dart'; import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/models/daily_activity.dart'; import 'package:papyrus/models/genre_stats.dart'; @@ -11,6 +12,42 @@ import 'package:papyrus/goals/goal_progress.dart'; /// Time period for statistics filtering. enum StatsPeriod { week, month, year, allTime, custom } +enum StatisticsBucketSize { day, month, year } + +/// A chart interval with exact values and explicit calendar boundaries. +class StatisticsActivity extends DailyActivity { + StatisticsActivity({ + required super.date, + required this.endDate, + required this.seconds, + required this.pages, + required this.bucketSize, + required super.booksRead, + }) : super(readingMinutes: seconds ~/ 60, pagesRead: pages.floor()); + + final DateTime endDate; + final int seconds; + final double pages; + final StatisticsBucketSize bucketSize; + + double get minutes => seconds / 60; + + String get intervalLabel => switch (bucketSize) { + StatisticsBucketSize.day => DateFormat.yMMMd().format(date), + StatisticsBucketSize.month => DateFormat.yMMM().format(date), + StatisticsBucketSize.year => DateFormat.y().format(date), + }; + + String get axisLabel => switch (bucketSize) { + StatisticsBucketSize.day => DateFormat.MMMd().format(date), + StatisticsBucketSize.month => DateFormat('MMM yy').format(date), + StatisticsBucketSize.year => DateFormat.y().format(date), + }; + + @override + String get readingTimeLabel => seconds > 0 && seconds < 60 ? '${seconds}s' : super.readingTimeLabel; +} + /// Monthly reading statistics. class MonthlyStats { final int month; @@ -57,17 +94,29 @@ class SessionStats { final int totalSessions; final int totalMinutes; final int totalPages; + final int totalSeconds; - const SessionStats({required this.totalSessions, required this.totalMinutes, required this.totalPages}); + const SessionStats({ + required this.totalSessions, + required this.totalMinutes, + required this.totalPages, + int? totalSeconds, + }) : totalSeconds = totalSeconds ?? totalMinutes * 60; /// Average session duration in minutes. - double get averageSessionDuration => totalSessions > 0 ? totalMinutes / totalSessions : 0; + double get averageSessionDuration => totalSessions > 0 ? totalSeconds / 60 / totalSessions : 0; /// Reading velocity (pages per hour). double get pagesPerHour => totalMinutes > 0 ? (totalPages / totalMinutes) * 60 : 0; /// Formatted average session duration. String get averageSessionLabel { + final seconds = (averageSessionDuration * 60).round(); + + if (seconds > 0 && seconds < 60) { + return '${seconds}s'; + } + final avg = averageSessionDuration.round(); if (avg < 60) { @@ -91,6 +140,10 @@ class SessionStats { /// Provider for statistics page state management. /// Uses DataStore as the single source of truth. class StatisticsProvider extends ChangeNotifier { + StatisticsProvider({DateTime Function()? now}) : _now = now ?? DateTime.now; + + final DateTime Function() _now; + GoalProgress? _periodTotals; DataStore? _dataStore; // Loading state @@ -105,8 +158,8 @@ class StatisticsProvider extends ChangeNotifier { DateTime? _customEndDate; // Chart data - List _readingTimeData = []; - List _pagesReadData = []; + List _readingTimeData = []; + List _pagesReadData = []; List _monthlyStats = []; List _genreDistribution = []; ReadingStreak _streak = ReadingStreak.empty; @@ -183,7 +236,7 @@ class StatisticsProvider extends ChangeNotifier { } final range = _getDateRangeForPeriod(); - final now = DateTime.now().toUtc(); + final now = _now().toUtc(); final periods = {}; for (final record in _dataStore!.goalPeriods) { @@ -210,24 +263,50 @@ class StatisticsProvider extends ChangeNotifier { .length; } - /// Total reading minutes in the selected period. - int get totalReadingMinutes { - if (_dataStore == null) { - return 0; - } + int get totalReadingSeconds => _periodTotals?.seconds ?? 0; + int get totalReadingMinutes => totalReadingSeconds ~/ 60; + int get pagesRead => (_periodTotals?.pages ?? 0).floor(); + bool get hasEstimatedPages => _periodTotals?.estimated ?? false; + /// Uses the same five-minute qualification as reading-day goals and streaks. + int get daysRead => _periodTotals?.days ?? 0; + + Set get _readBookIds { final range = _getDateRangeForPeriod(); - return _dataStore!.activityTotals(range.start, range.end).seconds ~/ 60; + return { + for (final activity in _periodTotals?.activities ?? []) + if (activity.kind == 'reading') activity.bookId, + ..._completedBooks(range.start, range.end), + }; } - /// Pages read in the selected period. - int get pagesRead { - if (_dataStore == null) { - return 0; - } + int get booksRead => _readBookIds.length; + int get periodDays { final range = _getDateRangeForPeriod(); - return _dataStore!.activityTotals(range.start, range.end).pages.floor(); + return DateTime.utc( + range.end.year, + range.end.month, + range.end.day, + ).difference(DateTime.utc(range.start.year, range.start.month, range.start.day)).inDays.clamp(1, 1000000); + } + + String get activityIntervalLabel => switch (bucketSize) { + StatisticsBucketSize.day => 'Daily totals', + StatisticsBucketSize.month => 'Monthly totals', + StatisticsBucketSize.year => 'Yearly totals', + }; + + StatisticsBucketSize get bucketSize { + if (_selectedPeriod == StatsPeriod.year) { + return StatisticsBucketSize.month; + } + + if (periodDays <= 60) { + return StatisticsBucketSize.day; + } + + return periodDays <= 366 * 5 ? StatisticsBucketSize.month : StatisticsBucketSize.year; } /// Session statistics for the selected period. @@ -247,17 +326,26 @@ class StatisticsProvider extends ChangeNotifier { ), ); - return SessionStats(totalSessions: sessions.length, totalMinutes: totalReadingMinutes, totalPages: pagesRead); + return SessionStats( + totalSessions: sessions.length, + totalMinutes: totalReadingMinutes, + totalPages: pagesRead, + totalSeconds: totalReadingSeconds, + ); } - List get readingTimeData => _readingTimeData; - List get pagesReadData => _pagesReadData; + List get readingTimeData => _readingTimeData; + List get pagesReadData => _pagesReadData; List get monthlyStats => _monthlyStats; List get genreDistribution => _genreDistribution; ReadingStreak get streak => _streak; /// Total reading time formatted (e.g., "3.5h"). String get totalReadingLabel { + if (totalReadingSeconds > 0 && totalReadingSeconds < 60) { + return '${totalReadingSeconds}s'; + } + final hours = totalReadingMinutes / 60; if (hours < 1) { @@ -271,30 +359,19 @@ class StatisticsProvider extends ChangeNotifier { return '${hours.toStringAsFixed(1)}h'; } - /// Average reading time per day for the period. + /// Includes every calendar day in the selected period, including today. String get averageReadingLabel { - if (_readingTimeData.isEmpty) { - return '0m'; - } - - final avgMinutes = _readingTimeData.averageMinutes; + final seconds = (totalReadingSeconds / periodDays).round(); - if (avgMinutes < 60) { - return '${avgMinutes}m'; + if (seconds > 0 && seconds < 60) { + return '${seconds}s'; } - final hours = avgMinutes / 60; - return '${hours.toStringAsFixed(1)}h'; + final minutes = (seconds / 60).round(); + return minutes < 60 ? '${minutes}m' : '${(minutes / 60).toStringAsFixed(1)}h'; } - /// Average daily reading time in minutes. - int get averageDailyMinutes { - if (_readingTimeData.isEmpty) { - return 0; - } - - return _readingTimeData.averageMinutes; - } + int get averageDailyMinutes => (totalReadingSeconds / 60 / periodDays).round(); /// Period label for display. String get periodLabel { @@ -342,8 +419,6 @@ class StatisticsProvider extends ChangeNotifier { notifyListeners(); try { - // Simulate network delay for realistic UX - await Future.delayed(const Duration(milliseconds: 100)); _updateDataForPeriod(); _isLoading = false; notifyListeners(); @@ -384,7 +459,7 @@ class StatisticsProvider extends ChangeNotifier { } ({DateTime start, DateTime end}) _getDateRangeForPeriod() { - final now = DateTime.now(); + final now = _now(); switch (_selectedPeriod) { case StatsPeriod.week: @@ -398,11 +473,23 @@ class StatisticsProvider extends ChangeNotifier { case StatsPeriod.year: return (start: DateTime(now.year, 1, 1), end: DateTime(now.year, now.month, now.day + 1)); case StatsPeriod.allTime: - return (start: DateTime(2000), end: DateTime(now.year, now.month, now.day + 1)); + final trackedBooks = { + for (final activity in _dataStore?.readingActivities ?? []) + if (activity.kind == 'completion') activity.bookId, + }; + + final dates = [ + for (final activity in _dataStore?.effectiveReadingActivities ?? []) + activity.startTime.toLocal(), + for (final book in _dataStore?.books ?? []) + if (book.completedAt != null && !trackedBooks.contains(book.id)) book.completedAt!.toLocal(), + ].where((date) => !date.isAfter(now)).toList()..sort(); + final first = dates.isEmpty ? now : dates.first; + return (start: DateTime(first.year, first.month, first.day), end: DateTime(now.year, now.month, now.day + 1)); case StatsPeriod.custom: if (_customStartDate != null && _customEndDate != null) { return ( - start: _customStartDate!, + start: DateTime(_customStartDate!.year, _customStartDate!.month, _customStartDate!.day), end: DateTime(_customEndDate!.year, _customEndDate!.month, _customEndDate!.day + 1), ); } @@ -410,16 +497,28 @@ class StatisticsProvider extends ChangeNotifier { } } + ({DateTime start, DateTime end}) _getChartDateRange() { + final range = _getDateRangeForPeriod(); + final start = range.start; + final end = switch (_selectedPeriod) { + StatsPeriod.week => DateTime(start.year, start.month, start.day + 7), + StatsPeriod.month => DateTime(start.year, start.month + 1), + StatsPeriod.year => DateTime(start.year + 1), + StatsPeriod.allTime || StatsPeriod.custom => range.end, + }; + + return (start: start, end: end); + } + void _updateDataForPeriod() { - final books = _dataStore?.books ?? []; + final range = _getDateRangeForPeriod(); + _periodTotals = _dataStore?.activityTotals(range.start, range.end); + final readBookIds = _readBookIds; final genres = {}; - for (final book in books) { - final genre = book.customMetadata?['genre']; - - if (genre is String && genre.trim().isNotEmpty) { - genres.update(genre.trim(), (n) => n + 1, ifAbsent: () => 1); - } + for (final bookId in readBookIds) { + final genre = _dataStore?.getBook(bookId)?.genre ?? 'Unspecified'; + genres.update(genre, (count) => count + 1, ifAbsent: () => 1); } final count = genres.values.fold(0, (total, value) => total + value); @@ -428,7 +527,7 @@ class StatisticsProvider extends ChangeNotifier { .map((entry) => GenreStats(genre: entry.key, bookCount: entry.value, percentage: entry.value / count)) .toList(); - final now = DateTime.now(); + final now = _now(); final qualified = _dataStore?.activityTotals(DateTime.utc(1900), now.toUtc()).qualifiedDays.toList() ?? []; qualified.sort(); var best = 0; @@ -476,45 +575,71 @@ class StatisticsProvider extends ChangeNotifier { _pagesReadData = _readingTimeData; } - List _generateActivityData() { - if (_dataStore == null) { + List _generateActivityData() { + final store = _dataStore; + + if (store == null) { return []; } - final range = _getDateRangeForPeriod(); - final end = DateTime(range.end.year, range.end.month, range.end.day); - final count = end.difference(range.start).inDays.clamp(1, 60); - final first = DateTime(end.year, end.month, end.day - count); - - return List.generate(count, (i) { - final day = DateTime(first.year, first.month, first.day + i); - final totals = _dataStore!.activityTotals(day, DateTime(day.year, day.month, day.day + 1)); - - return DailyActivity( - date: day, - readingMinutes: totals.seconds ~/ 60, - pagesRead: totals.pages.floor(), - booksRead: _completedBooks(day, DateTime(day.year, day.month, day.day + 1)).toList(), + final range = _getChartDateRange(); + final elapsedEnd = _getDateRangeForPeriod().end; + final size = bucketSize; + final activities = []; + var cursor = range.start; + + while (cursor.isBefore(range.end)) { + final boundary = switch (size) { + StatisticsBucketSize.day => DateTime(cursor.year, cursor.month, cursor.day + 1), + StatisticsBucketSize.month => DateTime(cursor.year, cursor.month + 1), + StatisticsBucketSize.year => DateTime(cursor.year + 1), + }; + + final end = boundary.isAfter(range.end) ? range.end : boundary; + final dataEnd = end.isAfter(elapsedEnd) ? elapsedEnd : end; + final totals = cursor.isBefore(dataEnd) ? store.activityTotals(cursor, dataEnd) : null; + + activities.add( + StatisticsActivity( + date: cursor, + endDate: end, + seconds: totals?.seconds ?? 0, + pages: totals?.pages ?? 0, + bucketSize: size, + booksRead: cursor.isBefore(dataEnd) ? _completedBooks(cursor, dataEnd).toList() : const [], + ), ); - }); + + cursor = end; + } + + return activities; } List _generateMonthlyStats() { - final now = DateTime.now(); - - return List.generate(12, (i) { - final start = DateTime(now.year, now.month - i); - final end = DateTime(start.year, start.month + 1); - final totals = _dataStore?.activityTotals(start, end); - - return MonthlyStats( - month: start.month, - year: start.year, - booksRead: _completedBooks(start, end).length, - pagesRead: totals?.pages.floor() ?? 0, - readingMinutes: (totals?.seconds ?? 0) ~/ 60, + final range = _getDateRangeForPeriod(); + final months = []; + var cursor = range.start; + + while (cursor.isBefore(range.end)) { + final boundary = DateTime(cursor.year, cursor.month + 1); + final end = boundary.isAfter(range.end) ? range.end : boundary; + final totals = _dataStore?.activityTotals(cursor, end); + + months.add( + MonthlyStats( + month: cursor.month, + year: cursor.year, + booksRead: _completedBooks(cursor, end).length, + pagesRead: totals?.pages.floor() ?? 0, + readingMinutes: (totals?.seconds ?? 0) ~/ 60, + ), ); - }).reversed.toList(); + + cursor = end; + } + + return months; } String _formatShortDate(DateTime date) { diff --git a/app/lib/reader/reading_activity_tracker.dart b/app/lib/reader/reading_activity_tracker.dart index 0076e40f..6c6beb64 100644 --- a/app/lib/reader/reading_activity_tracker.dart +++ b/app/lib/reader/reading_activity_tracker.dart @@ -37,7 +37,7 @@ class ReadingActivityTracker { bool _foreground = true; bool _disposed = false; DateTime? _started; - DateTime? _exposedSince; + final Map _exposedSince = {}; String _coverageKey = ''; final List _pending = []; Map? _pendingPatch; @@ -59,16 +59,12 @@ class ReadingActivityTracker { _event = event; _coverageKey = key; - if (changed) { - _exposedSince = null; - } - if (!_active) { _started = null; - _exposedSince = null; + _exposedSince.clear(); } else { _started ??= now; - _exposedSince ??= now; + _updateExposures(now); } if (event.locator != null) { @@ -80,6 +76,18 @@ class ReadingActivityTracker { } } + String _exposureKey(ReaderContentCoverage extent) => + extent.chapterCount == null ? extent.key : jsonEncode([extent.key, extent.start, extent.end]); + + void _updateExposures(DateTime now) { + final keys = _event!.coverage.map(_exposureKey).toSet(); + _exposedSince.removeWhere((key, _) => !keys.contains(key)); + + for (final key in keys) { + _exposedSince.putIfAbsent(key, () => now); + } + } + void updateScope(List ids) { if (_disposed || ids.length == shelfIds.length && ids.every(shelfIds.contains)) { return; @@ -98,7 +106,12 @@ class ReadingActivityTracker { _capture(_now().toUtc()); _foreground = foreground; _started = _active ? _now().toUtc() : null; - _exposedSince = _started; + _exposedSince.clear(); + + if (_active) { + _updateExposures(_started!); + } + unawaited(_drain()); } @@ -127,29 +140,29 @@ class ReadingActivityTracker { final coverage = []; - if (_exposedSince != null && now.difference(_exposedSince!) >= exposureThreshold) { - for (final extent in _event!.coverage) { - if (extent.end <= extent.start) { - continue; - } + for (final extent in _event!.coverage) { + final exposedSince = _exposedSince[_exposureKey(extent)]; - final chapterCount = extent.chapterCount; - final pageCount = book.pageCount; + if (extent.end <= extent.start || exposedSince == null || now.difference(exposedSince) < exposureThreshold) { + continue; + } - if (chapterCount != null && (pageCount == null || pageCount <= 0)) { - continue; - } + final chapterCount = extent.chapterCount; + final pageCount = book.pageCount; - coverage.add( - PageCoverage( - key: '${book.id}:${extent.key}', - start: extent.start, - end: extent.end, - pagesPerUnit: chapterCount == null ? 1 : pageCount! / chapterCount, - estimated: chapterCount != null, - ), - ); + if (chapterCount != null && (pageCount == null || pageCount <= 0)) { + continue; } + + coverage.add( + PageCoverage( + key: '${book.id}:${extent.key}', + start: chapterCount == null ? 0 : extent.start, + end: chapterCount == null ? 1 : extent.end, + pagesPerUnit: chapterCount == null ? 1 : pageCount! / chapterCount, + estimated: chapterCount != null, + ), + ); } _pending.add( diff --git a/app/lib/services/license_packages.dart b/app/lib/services/license_packages.dart new file mode 100644 index 00000000..06736d40 --- /dev/null +++ b/app/lib/services/license_packages.dart @@ -0,0 +1,15 @@ +import 'package:flutter/foundation.dart'; + +typedef LicensePackages = Map>; + +Future loadLicensePackages() async { + final packages = >{}; + + await for (final entry in LicenseRegistry.licenses) { + for (final package in entry.packages) { + (packages[package] ??= []).add(entry); + } + } + + return packages; +} 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/themes/app_theme.dart b/app/lib/themes/app_theme.dart index 560a84c4..d11b39d8 100644 --- a/app/lib/themes/app_theme.dart +++ b/app/lib/themes/app_theme.dart @@ -341,9 +341,8 @@ class AppTheme { static final _switchTheme = SwitchThemeData( overlayColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.hovered) && - !states.contains(WidgetState.focused) && - !states.contains(WidgetState.pressed)) { + if (states.contains(WidgetState.pressed) || + (states.contains(WidgetState.hovered) && !states.contains(WidgetState.focused))) { return Colors.transparent; } diff --git a/app/lib/themes/card_overlay_colors.dart b/app/lib/themes/card_overlay_colors.dart new file mode 100644 index 00000000..73073fc1 --- /dev/null +++ b/app/lib/themes/card_overlay_colors.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +class CardOverlayColors { + CardOverlayColors._(); + + static const background = Color(0x99000000); + + static Color selection(ColorScheme colors) => + _foreground(colors.brightness == Brightness.light ? colors.primaryContainer : colors.primary); + + static Color favorite(ColorScheme colors) => + _foreground(colors.brightness == Brightness.light ? colors.errorContainer : colors.error); + + static Color _foreground(Color preferred) { + // A white cover is the brightest possible backing behind the dark scrim. + // High-contrast and e-ink schemes can supply dark container colors. + final brightestBackground = Color.alphaBlend(background, Colors.white); + final contrast = (preferred.computeLuminance() + 0.05) / (brightestBackground.computeLuminance() + 0.05); + + if (contrast >= 3) { + return preferred; + } + + return Colors.white; + } +} 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/updates/update_controller.dart b/app/lib/updates/update_controller.dart index 68681117..92f6e8cc 100644 --- a/app/lib/updates/update_controller.dart +++ b/app/lib/updates/update_controller.dart @@ -9,6 +9,18 @@ import 'package:papyrus/updates/update_platform.dart' as platform; import 'package:papyrus/updates/update_release.dart'; import 'package:shared_preferences/shared_preferences.dart'; +enum UpdateStatus { + unchecked, + checking, + upToDate, + available, + installing, + staged, + handedOff, + checkFailed, + installFailed, +} + class UpdateController extends ChangeNotifier { static const checkFailureMessage = 'Could not check for updates. Try again.'; @@ -20,6 +32,7 @@ class UpdateController extends ChangeNotifier { final Future Function(UpdateAsset?, void Function(double?)) installer; UpdateChannel? channel; UpdateRelease? release; + UpdateStatus status = UpdateStatus.unchecked; bool busy = false; bool checking = false; bool available = false; @@ -39,6 +52,11 @@ class UpdateController extends ChangeNotifier { this.installer = platform.installUpdate, }); + DateTime? get lastChecked { + final timestamp = preferences.getInt('updates.lastCheck'); + return timestamp == null ? null : DateTime.fromMillisecondsSinceEpoch(timestamp); + } + bool get automatic => preferences.getBool('updates.automatic') ?? true; bool get external => channel == UpdateChannel.apt || channel == UpdateChannel.manual; @@ -78,6 +96,7 @@ class UpdateController extends ChangeNotifier { busy = true; checking = true; + status = UpdateStatus.checking; _notify(); try { @@ -103,12 +122,14 @@ class UpdateController extends ChangeNotifier { } } + status = available ? UpdateStatus.available : UpdateStatus.upToDate; message = available ? 'A newer version of Papyrus is available.' : 'Papyrus is up to date.'; await preferences.setInt('updates.lastCheck', DateTime.now().millisecondsSinceEpoch); } catch (_) { available = false; release = null; message = checkFailureMessage; + status = UpdateStatus.checkFailed; } finally { checking = false; busy = false; @@ -130,6 +151,7 @@ class UpdateController extends ChangeNotifier { busy = true; progress = null; + status = UpdateStatus.installing; message = 'Preparing the update…'; _notify(); @@ -152,8 +174,10 @@ class UpdateController extends ChangeNotifier { }); staged = channel == UpdateChannel.windows || channel == UpdateChannel.appImage; + status = staged ? UpdateStatus.staged : UpdateStatus.handedOff; } catch (_) { message = 'Could not finish the update. Please try again.'; + status = UpdateStatus.installFailed; } finally { busy = false; progress = null; diff --git a/app/lib/updates/update_settings.dart b/app/lib/updates/update_settings.dart index 8f1faadd..e9257c91 100644 --- a/app/lib/updates/update_settings.dart +++ b/app/lib/updates/update_settings.dart @@ -1,11 +1,9 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:papyrus/services/about_links.dart'; -import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/pages/app_updates_page.dart'; import 'package:papyrus/themes/app_motion.dart'; import 'package:papyrus/updates/update_controller.dart'; import 'package:papyrus/updates/update_release.dart'; -import 'package:papyrus/widgets/settings/settings_row.dart'; Future showUpdateSettings(BuildContext context, UpdateController controller) async { if (controller.viewingSettings) { @@ -15,90 +13,13 @@ Future showUpdateSettings(BuildContext context, UpdateController controlle controller.setViewingSettings(true); try { - await showDialog( - context: context, - animationStyle: AppMotion.animationStyle(context), - builder: (context) => ListenableBuilder( - listenable: controller, - builder: (context, child) => AlertDialog( - title: const Text('App updates'), - contentPadding: const EdgeInsets.fromLTRB(Spacing.lg, Spacing.md, Spacing.lg, Spacing.sm), - content: SizedBox( - width: 420, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(controller.message), - const SizedBox(height: Spacing.sm), - Theme( - data: Theme.of(context).copyWith( - splashFactory: NoSplash.splashFactory, - highlightColor: Colors.transparent, - ), - child: SettingsToggleRow( - label: 'Check automatically', - value: controller.automatic, - onChanged: controller.setAutomatic, - padding: const EdgeInsets.symmetric(vertical: Spacing.xs), - ), - ), - if (controller.channel == UpdateChannel.apt) ...[ - const SizedBox(height: Spacing.md), - const Text( - 'Install updates through your system’s software updater. Enable the Papyrus repository once to receive future releases.', - ), - TextButton( - onPressed: () => AboutLinks.open( - context, - Uri.https('github.com', '/PapyrusReader/client/blob/master/docs/INSTALL.md'), - ), - child: const Text('Repository setup'), - ), - ], - if (controller.channel == UpdateChannel.manual) ...[ - const SizedBox(height: Spacing.md), - const Text( - 'This portable or development build uses manual installation. Install a supported package to receive integrated updates.', - ), - TextButton( - onPressed: () => AboutLinks.open(context, AboutLinks.releases), - child: const Text('Downloads'), - ), - ], - if (controller.available && !controller.external && !controller.staged) ...[ - const SizedBox(height: Spacing.md), - Text(switch (controller.channel) { - UpdateChannel.windows => 'The update will install after you close Papyrus normally.', - UpdateChannel.appImage => 'The update takes effect when you close and reopen Papyrus.', - UpdateChannel.web => 'Reload when you have finished reading to use the latest version.', - _ => 'Your device will guide you through installation.', - }), - ], - ], - ), - ), - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Close')), - if (controller.available && !controller.external && !controller.staged) - FilledButton( - onPressed: controller.busy ? null : controller.install, - child: Text(controller.channel == UpdateChannel.web ? 'Reload' : 'Update'), - ) - else - FilledButton( - onPressed: controller.busy || controller.staged ? null : controller.check, - key: const ValueKey('check-updates'), - child: IndexedStack( - index: controller.checking ? 1 : 0, - alignment: Alignment.center, - children: const [Text('Check now'), Text('Checking…')], - ), - ), - ], - ), + final page = AppUpdatesPage(controller: controller); + + await Navigator.of(context).push( + PageRouteBuilder( + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + pageBuilder: (_, _, _) => page, ), ); } finally { diff --git a/app/lib/widgets/add_book/connect_folder_sheet.dart b/app/lib/widgets/add_book/connect_folder_sheet.dart index 166f4cf5..04bc3229 100644 --- a/app/lib/widgets/add_book/connect_folder_sheet.dart +++ b/app/lib/widgets/add_book/connect_folder_sheet.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/folders/directory_access.dart'; @@ -46,7 +48,7 @@ Future connectBookFolder(BuildContext context, {Future? afterDism context, builder: (context) => FolderSheet( title: 'Could not connect folder', - body: Text('$error'), + body: Text(_folderErrorMessage(error)), footer: BottomSheetActions( primary: OutlinedButton(onPressed: () => Navigator.pop(context), child: const Text('Close')), ), @@ -77,26 +79,36 @@ class _ConnectFolderSheetState extends State { FolderPreview? _preview; String? _error; bool _saving = false; - bool _scanning = false; + bool _showScanProgress = false; + Timer? _scanProgressTimer; + late Future _pendingScan; int _revision = 0; @override void initState() { super.initState(); - _scan(); + _startScan(); } @override void dispose() { + _scanProgressTimer?.cancel(); _name.dispose(); super.dispose(); } + void _startScan() { + _pendingScan = _scan(); + } + Future _scan() async { final revision = ++_revision; - setState(() { - _scanning = true; - _error = null; + _scanProgressTimer?.cancel(); + setState(() => _error = null); + _scanProgressTimer = Timer(AnimationDurations.standard, () { + if (mounted && revision == _revision) { + setState(() => _showScanProgress = true); + } }); try { @@ -107,16 +119,18 @@ class _ConnectFolderSheetState extends State { ); if (mounted && revision == _revision) { + _scanProgressTimer?.cancel(); setState(() { _preview = preview; - _scanning = false; + _showScanProgress = false; }); } } catch (error) { if (mounted && revision == _revision) { + _scanProgressTimer?.cancel(); setState(() { - _error = '$error'; - _scanning = false; + _error = _folderErrorMessage(error); + _showScanProgress = false; }); } } @@ -126,6 +140,18 @@ class _ConnectFolderSheetState extends State { setState(() => _saving = true); try { + // A quick recheck keeps the action available, but must finish before importing. + await _pendingScan; + + if (!mounted) { + return; + } + + if (_error != null || _preview == null) { + setState(() => _saving = false); + return; + } + if (widget.scope != widget.folders.scope) { throw StateError('The selected library changed. Choose this folder again.'); } @@ -139,7 +165,7 @@ class _ConnectFolderSheetState extends State { if (mounted) { setState(() { _saving = false; - _error = '$error'; + _error = _folderErrorMessage(error); }); } } @@ -153,7 +179,8 @@ class _ConnectFolderSheetState extends State { final textTheme = Theme.of(context).textTheme; final secondaryStyle = textTheme.bodyMedium?.copyWith(color: colors.onSurfaceVariant); final problemCount = (preview?.unsupported ?? 0) + (preview?.inaccessible ?? 0); - final canImport = !_saving && !_scanning && preview != null && _error == null && _name.text.trim().isNotEmpty; + final canImport = + !_saving && !_showScanProgress && preview != null && _error == null && _name.text.trim().isNotEmpty; return FolderSheet( title: widget.grant.persistent ? 'Connect folder' : 'Import folder', @@ -162,6 +189,13 @@ class _ConnectFolderSheetState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + if (_error != null) ...[ + Semantics( + liveRegion: true, + child: Text(_error!, style: TextStyle(color: colors.error)), + ), + const SizedBox(height: Spacing.md), + ], FolderLocationField(grant: widget.grant), const SizedBox(height: Spacing.md), TextField( @@ -182,13 +216,13 @@ class _ConnectFolderSheetState extends State { ? null : (value) { setState(() => _recursive = value); - _scan(); + _startScan(); }, ), const Divider(height: Spacing.lg), SizedBox( height: Spacing.xs, - child: _scanning + child: _showScanProgress ? const AppLinearProgressIndicator(semanticsLabel: 'Checking folder', minHeight: Spacing.xs) : null, ), @@ -216,17 +250,6 @@ class _ConnectFolderSheetState extends State { Text('Select this folder again to import new books.', style: secondaryStyle), ], ], - if (_error != null) ...[ - const SizedBox(height: Spacing.md), - Semantics( - liveRegion: true, - child: Text(_error!, style: TextStyle(color: colors.error)), - ), - Align( - alignment: Alignment.centerLeft, - child: TextButton(onPressed: _saving ? null : _scan, child: const Text('Check folder again')), - ), - ], ], ), footer: BottomSheetFormActions( @@ -238,6 +261,8 @@ class _ConnectFolderSheetState extends State { } } +String _folderErrorMessage(Object error) => error is StateError ? error.message : '$error'; + String _previewSize(FolderPreview? preview) { if (preview == null) { return '—'; diff --git a/app/lib/widgets/book_details/book_info_grid.dart b/app/lib/widgets/book_details/book_info_grid.dart index 2b988c48..1cf4d26a 100644 --- a/app/lib/widgets/book_details/book_info_grid.dart +++ b/app/lib/widgets/book_details/book_info_grid.dart @@ -20,6 +20,11 @@ class BookInfoGrid extends StatelessWidget { List<_InfoEntry> _buildEntries() { final entries = <_InfoEntry>[]; entries.add(_InfoEntry('Format', book.formatLabel)); + final genre = book.genre; + + if (genre != null) { + entries.add(_InfoEntry('Genre', genre)); + } if (book.pageCount != null) { entries.add(_InfoEntry('Pages', '${book.pageCount}')); diff --git a/app/lib/widgets/folders/folder_details_sheet.dart b/app/lib/widgets/folders/folder_details_sheet.dart index 56871212..3b7bcf4d 100644 --- a/app/lib/widgets/folders/folder_details_sheet.dart +++ b/app/lib/widgets/folders/folder_details_sheet.dart @@ -173,28 +173,60 @@ class _FolderDetailsSheetState extends State { color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), shape: theme.cardTheme.shape ?? const RoundedRectangleBorder(), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Wrap( - spacing: Spacing.md, - runSpacing: Spacing.xs, - crossAxisAlignment: WrapCrossAlignment.center, + child: LayoutBuilder( + builder: (context, constraints) { + final status = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - FolderStatusLabel(folder: folder, shelfMissing: shelfMissing), - if (recovery != null) - TextButton( - onPressed: enabled ? () => _action(recovery.$2) : null, - child: Text(recovery.$1), + FolderStatusLabel( + folder: folder, + shelfMissing: shelfMissing, + reserveHeight: recovery == null, + ), + if (folder.error != null) + Padding( + padding: const EdgeInsets.only(top: Spacing.sm), + child: Text(folder.error!, style: secondary), ), ], - ), - if (folder.error != null) - Padding( - padding: const EdgeInsets.only(bottom: Spacing.sm), - child: Text(folder.error!, style: secondary), + ); + + if (recovery == null) { + return status; + } + + final action = FilledButton( + style: FilledButton.styleFrom( + minimumSize: const Size(0, TouchTargets.mobileRecommended), + padding: const EdgeInsets.symmetric( + horizontal: Spacing.buttonPaddingHorizontal, + vertical: Spacing.sm, + ), ), - ], + onPressed: enabled ? () => _action(recovery.$2) : null, + child: Text(recovery.$1, textAlign: TextAlign.center), + ); + + if (constraints.maxWidth < MediaQuery.textScalerOf(context).scale(280)) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + status, + const SizedBox(height: Spacing.sm), + Align(alignment: Alignment.centerRight, child: action), + ], + ); + } + + return Row( + children: [ + Expanded(child: status), + const SizedBox(width: Spacing.md), + action, + ], + ); + }, ), ), FolderProgressBar(folder: folder), @@ -221,20 +253,36 @@ class _FolderDetailsSheetState extends State { const SizedBox(height: Spacing.md), const Divider(height: 1), const SizedBox(height: Spacing.md), - Wrap( - spacing: Spacing.xl, - runSpacing: Spacing.sm, - children: [ - _FolderProperty( - label: 'Destination shelf', - value: shelfMissing ? 'Unavailable' : shelf?.name ?? 'No shelf', - ), - _FolderProperty(label: 'Subfolders', value: folder.recursive ? 'Included' : 'Excluded'), - _FolderProperty( - label: 'Last successful check', - value: folderLastChecked(folder, includeLabel: false), - ), - ], + LayoutBuilder( + builder: (context, constraints) { + final singleRow = constraints.maxWidth >= MediaQuery.textScalerOf(context).scale(280); + final width = singleRow ? (constraints.maxWidth - Spacing.sm * 2) / 3 : constraints.maxWidth; + + return Wrap( + spacing: Spacing.sm, + runSpacing: Spacing.sm, + children: [ + SizedBox( + width: width, + child: _FolderProperty( + label: 'Shelf', + value: shelfMissing ? 'Unavailable' : shelf?.name ?? 'No shelf', + ), + ), + SizedBox( + width: width, + child: _FolderProperty(label: 'Subfolders', value: folder.recursive ? 'Included' : 'Excluded'), + ), + SizedBox( + width: width, + child: _FolderProperty( + label: 'Last checked', + value: folderLastChecked(folder, includeLabel: false), + ), + ), + ], + ); + }, ), if (!guest && pending > 0) ...[ const SizedBox(height: Spacing.sm), @@ -267,19 +315,14 @@ class _FolderProperty extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); - return IntrinsicWidth( - child: Padding( - padding: const EdgeInsets.only(bottom: Spacing.sm), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant)), - const SizedBox(height: Spacing.xs), - SelectableText(value, style: theme.textTheme.bodyMedium), - ], - ), - ), + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant)), + const SizedBox(height: Spacing.xs), + SelectableText(value, style: theme.textTheme.bodyMedium), + ], ); } } diff --git a/app/lib/widgets/folders/folder_status.dart b/app/lib/widgets/folders/folder_status.dart index a45a1eb1..fd9f9116 100644 --- a/app/lib/widgets/folders/folder_status.dart +++ b/app/lib/widgets/folders/folder_status.dart @@ -8,10 +8,18 @@ String folderLastChecked(WatchedFolder folder, {bool includeLabel = true}) => fo : '${includeLabel ? 'Checked ' : ''}${DateFormat.yMMMd().add_Hm().format(folder.lastScan!.toLocal())}'; class FolderStatusLabel extends StatelessWidget { - const FolderStatusLabel({super.key, required this.folder, this.shelfMissing = false}); + const FolderStatusLabel({ + super.key, + required this.folder, + this.shelfMissing = false, + this.alignment = Alignment.centerLeft, + this.reserveHeight = true, + }); final WatchedFolder folder; final bool shelfMissing; + final AlignmentGeometry alignment; + final bool reserveHeight; @override Widget build(BuildContext context) { @@ -43,24 +51,27 @@ class FolderStatusLabel extends StatelessWidget { }; return SizedBox( - height: lineHeight * 2, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: IconSizes.small, color: color), - const SizedBox(width: Spacing.xs), - Flexible( - child: Text( - label, - style: style.copyWith(color: color), - maxLines: 2, - overflow: TextOverflow.ellipsis, - semanticsLabel: folder.status == FolderStatus.importing && progress != null - ? 'Importing books: ${progress.label}' - : null, + height: reserveHeight ? lineHeight * 2 : null, + child: Align( + alignment: alignment, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: IconSizes.small, color: color), + const SizedBox(width: Spacing.xs), + Flexible( + child: Text( + label, + style: style.copyWith(color: color), + maxLines: 2, + overflow: TextOverflow.ellipsis, + semanticsLabel: folder.status == FolderStatus.importing && progress != null + ? 'Importing books: ${progress.label}' + : null, + ), ), - ), - ], + ], + ), ), ); } diff --git a/app/lib/widgets/folders/folder_subfolders_switch.dart b/app/lib/widgets/folders/folder_subfolders_switch.dart index d9958187..e53e6171 100644 --- a/app/lib/widgets/folders/folder_subfolders_switch.dart +++ b/app/lib/widgets/folders/folder_subfolders_switch.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:papyrus/themes/design_tokens.dart'; class FolderSubfoldersSwitch extends StatelessWidget { const FolderSubfoldersSwitch({super.key, required this.value, required this.onChanged}); @@ -13,13 +14,16 @@ class FolderSubfoldersSwitch extends StatelessWidget { highlightColor: Colors.transparent, hoverColor: Colors.transparent, ), - child: SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Include subfolders'), - value: value, - onChanged: onChanged, - overlayColor: WidgetStateProperty.resolveWith( - (states) => states.contains(WidgetState.focused) ? null : Colors.transparent, + child: Padding( + padding: const EdgeInsets.only(top: Spacing.sm), + child: SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Include subfolders'), + value: value, + onChanged: onChanged, + overlayColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.focused) ? null : Colors.transparent, + ), ), ), ); diff --git a/app/lib/widgets/folders/folder_table.dart b/app/lib/widgets/folders/folder_table.dart index a6a88e98..e7f45c4f 100644 --- a/app/lib/widgets/folders/folder_table.dart +++ b/app/lib/widgets/folders/folder_table.dart @@ -106,7 +106,7 @@ class _FolderColumns extends StatelessWidget { Expanded(flex: 2, child: shelf), const SizedBox(width: Spacing.md), SizedBox(width: 136, child: checked), - SizedBox(width: 128, child: action), + SizedBox(width: MediaQuery.textScalerOf(context).scale(160), child: action), ], ); } @@ -145,7 +145,12 @@ class _FolderRow extends StatelessWidget { _ => null, }; final identity = FolderIdentity(folder: folder, titleStyle: theme.textTheme.titleMedium); - final status = FolderStatusLabel(folder: folder, shelfMissing: shelfMissing); + final status = FolderStatusLabel( + folder: folder, + shelfMissing: shelfMissing, + alignment: wide ? Alignment.centerLeft : Alignment.topLeft, + reserveHeight: wide, + ); final books = Text( '$count', style: theme.textTheme.bodyMedium, @@ -160,20 +165,13 @@ class _FolderRow extends StatelessWidget { height: actionHeight, child: action == null ? null - : TextButton( + : FilledButton( onPressed: busy ? null : () => onAction(action.$2), - style: TextButton.styleFrom( + style: FilledButton.styleFrom( minimumSize: const Size(0, TouchTargets.mobileRecommended), - padding: const EdgeInsets.symmetric(horizontal: Spacing.sm), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible(child: Text(action.$1, maxLines: 2, overflow: TextOverflow.ellipsis)), - const SizedBox(width: Spacing.xs), - const Icon(Icons.chevron_right, size: IconSizes.small), - ], + padding: EdgeInsets.symmetric(horizontal: wide ? Spacing.buttonPaddingHorizontal : Spacing.sm), ), + child: Text(action.$1, maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center), ), ); @@ -206,7 +204,13 @@ class _FolderRow extends StatelessWidget { : Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - identity, + Row( + children: [ + Expanded(child: identity), + const SizedBox(width: Spacing.sm), + SizedBox(width: 128, child: actionButton), + ], + ), const SizedBox(height: Spacing.md), _FolderSummary( status: status, @@ -214,7 +218,6 @@ class _FolderRow extends StatelessWidget { shelf: Text(shelfName), checked: Text(folderLastChecked(folder, includeLabel: false)), ), - Align(alignment: Alignment.centerRight, child: actionButton), ], ), ), @@ -245,8 +248,6 @@ class _FolderSummary extends StatelessWidget { _ => 1, }; final fields = [('Status', status), ('Books', books), ('Destination shelf', shelf), ('Last checked', checked)]; - final valueHeight = - textScale.scale(theme.textTheme.bodyMedium!.fontSize!) * (theme.textTheme.bodyMedium!.height ?? 1.4) * 2; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -268,18 +269,11 @@ class _FolderSummary extends StatelessWidget { style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), const SizedBox(height: Spacing.xs), - SizedBox( - height: valueHeight, - child: Align( - alignment: Alignment.centerLeft, - heightFactor: 1, - child: DefaultTextStyle( - style: theme.textTheme.bodyMedium!, - maxLines: 2, - overflow: TextOverflow.ellipsis, - child: fields[index].$2, - ), - ), + DefaultTextStyle( + style: theme.textTheme.bodyMedium!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + child: fields[index].$2, ), ], ), diff --git a/app/lib/widgets/goals/log_reading_sheet.dart b/app/lib/widgets/goals/log_reading_sheet.dart index dfd84ac7..28bdaf67 100644 --- a/app/lib/widgets/goals/log_reading_sheet.dart +++ b/app/lib/widgets/goals/log_reading_sheet.dart @@ -421,7 +421,7 @@ class ReadingActivityTile extends StatelessWidget { false => [ if (activity.seconds > 0) activity.seconds < 60 ? '${activity.seconds} sec' : '${activity.seconds ~/ 60} min', if (activity.pages > 0) '${activity.pages} pages', - if (activity.coverage.isNotEmpty) activity.isEstimated ? 'Estimated pages' : 'PDF pages', + if (activity.coverage.isNotEmpty) activity.isEstimated ? 'Estimated pages' : 'Reader pages', ].join(' · '), }; @@ -581,7 +581,7 @@ class _ActivityDetailsState extends State<_ActivityDetails> { final parts = [ if (activity.seconds > 0) _duration(activity.seconds), if (activity.pages > 0) '${activity.pages} ${activity.pages == 1 ? 'page' : 'pages'}', - if (activity.coverage.isNotEmpty) activity.isEstimated ? 'Estimated EPUB pages' : 'PDF pages', + if (activity.coverage.isNotEmpty) activity.isEstimated ? 'Estimated EPUB pages' : 'Reader pages', ]; return parts.isEmpty ? 'No reading time or pages recorded.' : parts.join(' · '); diff --git a/app/lib/widgets/library/acquisition_placeholder_card.dart b/app/lib/widgets/library/acquisition_placeholder_card.dart index 612fa5df..6d2368c9 100644 --- a/app/lib/widgets/library/acquisition_placeholder_card.dart +++ b/app/lib/widgets/library/acquisition_placeholder_card.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:papyrus/acquisition/acquisition_models.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/themes/card_overlay_colors.dart'; import 'package:papyrus/widgets/library/acquisition_status_text.dart'; import 'package:papyrus/themes/app_motion.dart'; import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; @@ -187,7 +188,7 @@ class _SelectionIconButton extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; return Material( - color: Colors.black45, + color: CardOverlayColors.background, borderRadius: BorderRadius.circular(AppRadius.full), child: InkWell( borderRadius: BorderRadius.circular(AppRadius.full), @@ -197,7 +198,7 @@ class _SelectionIconButton extends StatelessWidget { child: Icon( selected ? Icons.check_circle : Icons.radio_button_unchecked, size: IconSizes.small, - color: selected ? colorScheme.primary : Colors.white, + color: selected ? CardOverlayColors.selection(colorScheme) : Colors.white, ), ), ), diff --git a/app/lib/widgets/library/book_card.dart b/app/lib/widgets/library/book_card.dart index 7ccd49bf..886394f9 100644 --- a/app/lib/widgets/library/book_card.dart +++ b/app/lib/widgets/library/book_card.dart @@ -4,6 +4,7 @@ import 'package:papyrus/models/book.dart'; import 'package:papyrus/providers/book_storage_status_controller.dart'; import 'package:papyrus/providers/enums/library_reading_status.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/themes/card_overlay_colors.dart'; import 'package:papyrus/utils/book_actions.dart'; import 'package:papyrus/widgets/book/private_book_cover.dart'; import 'package:papyrus/widgets/library/acquisition_status_text.dart'; @@ -108,6 +109,7 @@ class _BookCardState extends State { margin: EdgeInsets.zero, clipBehavior: Clip.antiAlias, color: isUnavailable ? colorScheme.surfaceContainerLow : null, + shadowColor: colorScheme.brightness == Brightness.dark ? colorScheme.outline : null, child: InkWell( onTap: effectiveTap, child: Column( @@ -140,7 +142,7 @@ class _BookCardState extends State { child: _CardIconButton( tooltip: widget.isFavorite ? 'Remove from favorites' : 'Add to favorites', icon: widget.isFavorite ? Icons.favorite : Icons.favorite_border, - color: widget.isFavorite ? colorScheme.error : Colors.white, + color: widget.isFavorite ? CardOverlayColors.favorite(colorScheme) : Colors.white, onTap: widget.onToggleFavorite != null ? () => widget.onToggleFavorite!(widget.isFavorite) : null, @@ -157,7 +159,7 @@ class _BookCardState extends State { ? 'Deselect ${widget.book.title}' : 'Select ${widget.book.title}', icon: widget.isSelected ? Icons.check_circle : Icons.radio_button_unchecked, - color: widget.isSelected ? colorScheme.primary : Colors.white, + color: widget.isSelected ? CardOverlayColors.selection(colorScheme) : Colors.white, onTap: widget.onSelectToggle, ), ), @@ -466,7 +468,7 @@ class _CardIconButton extends StatelessWidget { container: true, button: true, child: Material( - color: Colors.black45, + color: CardOverlayColors.background, borderRadius: BorderRadius.circular(AppRadius.full), child: InkWell( borderRadius: BorderRadius.circular(AppRadius.full), 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/library/library_page_header.dart b/app/lib/widgets/library/library_page_header.dart index 688f2f2d..dd13337e 100644 --- a/app/lib/widgets/library/library_page_header.dart +++ b/app/lib/widgets/library/library_page_header.dart @@ -6,6 +6,42 @@ import 'package:papyrus/widgets/shared/app_page_bar.dart'; double libraryPageHorizontalPadding(BuildContext context) => MediaQuery.sizeOf(context).width >= Breakpoints.desktopSmall ? Spacing.lg : AppPageToolbar.padding.left; +/// Shared title placement for top-level Library collection pages. +class LibraryCollectionHeader extends StatelessWidget { + const LibraryCollectionHeader({super.key, required this.title, required this.onMenuPressed, this.actions = const []}); + + final String title; + final VoidCallback onMenuPressed; + final List actions; + + @override + Widget build(BuildContext context) { + final mobile = MediaQuery.sizeOf(context).width < Breakpoints.desktopSmall; + final textTheme = Theme.of(context).textTheme; + final heading = Text( + title, + style: mobile ? textTheme.titleLarge : textTheme.headlineSmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + + if (mobile) { + return LibraryMobileToolbar(search: heading, onMenuPressed: onMenuPressed, actions: actions); + } + + return Padding( + padding: const EdgeInsets.only(top: Spacing.lg, left: Spacing.lg, right: Spacing.lg), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: kMinInteractiveDimension), + child: LibraryToolbar( + search: Align(alignment: Alignment.centerLeft, heightFactor: 1, child: heading), + actions: actions, + ), + ), + ); + } +} + /// Page identity and contextual actions for nested Library routes. class LibraryPageHeader extends StatelessWidget { const LibraryPageHeader({ diff --git a/app/lib/widgets/profile/about_settings_section.dart b/app/lib/widgets/profile/about_settings_section.dart index d419234f..efb3acd7 100644 --- a/app/lib/widgets/profile/about_settings_section.dart +++ b/app/lib/widgets/profile/about_settings_section.dart @@ -6,14 +6,16 @@ import 'package:go_router/go_router.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:papyrus/services/about_links.dart'; import 'package:papyrus/services/app_info.dart'; +import 'package:papyrus/services/license_packages.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/settings/settings_row.dart'; import 'package:papyrus/widgets/settings/settings_section.dart'; class AboutSettingsSection extends StatefulWidget { final bool isDesktop; + final bool showHeader; - const AboutSettingsSection({super.key, this.isDesktop = false}); + const AboutSettingsSection({super.key, this.isDesktop = false, this.showHeader = true}); @override State createState() => _AboutSettingsSectionState(); @@ -21,6 +23,33 @@ class AboutSettingsSection extends StatefulWidget { class _AboutSettingsSectionState extends State { late final Future _packageInfo = loadAppInfo(); + late final Future _licenses; + bool _openingLicenses = false; + + @override + void initState() { + super.initState(); + _licenses = loadLicensePackages().then((packages) => packages, onError: (Object _) => null); + } + + Future _openLicenses() async { + if (_openingLicenses) { + return; + } + + _openingLicenses = true; + final route = ModalRoute.of(context); + + try { + final packages = await _licenses; + + if (mounted && (route?.isCurrent ?? true)) { + await context.push('/profile/licenses', extra: packages); + } + } finally { + _openingLicenses = false; + } + } @override Widget build(BuildContext context) { @@ -49,7 +78,7 @@ class _AboutSettingsSectionState extends State { value: updates.available ? 'Update available' : 'Check for updates', onTap: () => showUpdateSettings(context, updates), ), - SettingsRow(label: 'Licenses', onTap: () => context.push('/profile/licenses')), + SettingsRow(label: 'Licenses', onTap: _openLicenses), SettingsRow(label: 'Support', onTap: () => context.push('/profile/support')), SettingsRow( label: "What's new", @@ -70,7 +99,7 @@ class _AboutSettingsSectionState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SettingsSectionHeader(title: 'About'), + if (widget.showHeader) const SettingsSectionHeader(title: 'About'), ...rows, ], ); diff --git a/app/lib/widgets/profile/accessibility_settings_section.dart b/app/lib/widgets/profile/accessibility_settings_section.dart index 7a6d47ab..6cac9f06 100644 --- a/app/lib/widgets/profile/accessibility_settings_section.dart +++ b/app/lib/widgets/profile/accessibility_settings_section.dart @@ -8,8 +8,9 @@ import 'package:provider/provider.dart'; class AccessibilitySettingsSection extends StatelessWidget { final bool isDesktop; + final bool showHeader; - const AccessibilitySettingsSection({super.key, this.isDesktop = false}); + const AccessibilitySettingsSection({super.key, this.isDesktop = false, this.showHeader = true}); static String enlargementLabel(double value) => value == 1 ? 'System default' : '+${((value - 1) * 100).round()}%'; @@ -93,7 +94,7 @@ class AccessibilitySettingsSection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SettingsSectionHeader(title: 'Accessibility'), + if (showHeader) const SettingsSectionHeader(title: 'Accessibility'), ...children, ], ); diff --git a/app/lib/widgets/profile/account_settings_section.dart b/app/lib/widgets/profile/account_settings_section.dart index 0b4b557a..5c6cbf87 100644 --- a/app/lib/widgets/profile/account_settings_section.dart +++ b/app/lib/widgets/profile/account_settings_section.dart @@ -10,8 +10,9 @@ import 'package:provider/provider.dart'; /// Account settings section for ProfilePage. class AccountSettingsSection extends StatelessWidget { final bool isDesktop; + final bool showHeader; - const AccountSettingsSection({super.key, this.isDesktop = false}); + const AccountSettingsSection({super.key, this.isDesktop = false, this.showHeader = true}); @override Widget build(BuildContext context) { @@ -26,10 +27,11 @@ class AccountSettingsSection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SettingsSectionHeader( - title: 'Account', - padding: EdgeInsets.only(bottom: Spacing.md), - ), + if (showHeader) + const SettingsSectionHeader( + title: 'Account', + padding: EdgeInsets.only(bottom: Spacing.md), + ), _buildProfileForm(context), const SizedBox(height: Spacing.lg), const SettingsRow(label: 'Change password', enabled: false, showChevron: false), diff --git a/app/lib/widgets/profile/appearance_settings_section.dart b/app/lib/widgets/profile/appearance_settings_section.dart index ce0c10ce..26f9dcff 100644 --- a/app/lib/widgets/profile/appearance_settings_section.dart +++ b/app/lib/widgets/profile/appearance_settings_section.dart @@ -9,8 +9,9 @@ import 'package:provider/provider.dart'; /// Appearance and theme settings section for ProfilePage. class AppearanceSettingsSection extends StatelessWidget { final bool isDesktop; + final bool showHeader; - const AppearanceSettingsSection({super.key, this.isDesktop = false}); + const AppearanceSettingsSection({super.key, this.isDesktop = false, this.showHeader = true}); static String getThemeLabel(String theme) { switch (theme) { @@ -41,8 +42,9 @@ class AppearanceSettingsSection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SettingsSectionHeader(title: 'Appearance'), + if (showHeader) const SettingsSectionHeader(title: 'Appearance'), SettingsRow(label: 'Theme', value: getThemeLabel(prefs.themeModePref), onTap: () => _showThemePicker(context)), + _buildPageAlignment(context), ], ); } @@ -52,10 +54,28 @@ class AppearanceSettingsSection extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ SettingsCard(title: 'Theme', children: [_buildThemeRadioGroup(context)]), + const SizedBox(height: Spacing.lg), + SettingsCard(title: 'Page layout', children: [_buildPageAlignment(context)]), ], ); } + Widget _buildPageAlignment(BuildContext context) { + final prefs = context.watch(); + + return SettingsRow( + label: 'Page alignment', + value: prefs.pageAlignment == 'center' ? 'Center' : 'Left', + onTap: () => showProfilePickerSheet( + context, + title: 'Page alignment', + items: const [('Left', 'left'), ('Center', 'center')], + selected: prefs.pageAlignment, + onSelected: (value) => prefs.pageAlignment = value, + ), + ); + } + Widget _buildThemeRadioGroup(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; diff --git a/app/lib/widgets/profile/storage_sync_settings_section.dart b/app/lib/widgets/profile/storage_sync_settings_section.dart index 45d3c4a1..201e0dc0 100644 --- a/app/lib/widgets/profile/storage_sync_settings_section.dart +++ b/app/lib/widgets/profile/storage_sync_settings_section.dart @@ -3,16 +3,13 @@ import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/media/media_upload_queue.dart'; import 'package:papyrus/media/media_storage_scope.dart'; import 'package:papyrus/powersync/powersync_service.dart'; import 'package:papyrus/powersync/storage_sync_controller.dart'; import 'package:papyrus/powersync/sync_state.dart'; -import 'package:papyrus/providers/acquisition_availability_provider.dart'; import 'package:papyrus/providers/auth_provider.dart'; -import 'package:papyrus/providers/preferences_provider.dart'; import 'package:papyrus/providers/sync_settings_provider.dart'; import 'package:papyrus/services/book_import_service_stub.dart' if (dart.library.js_interop) 'package:papyrus/services/book_import_service.dart'; @@ -22,13 +19,14 @@ import 'package:papyrus/widgets/settings/settings_row.dart'; import 'package:papyrus/widgets/settings/settings_section.dart'; import 'package:provider/provider.dart'; -/// Storage, synchronization, and acquisition settings section. +/// Storage and synchronization settings section. /// /// Supports both mobile ListView presentation and desktop card layout. class StorageSyncSettingsSection extends StatelessWidget { final bool isDesktop; + final bool showHeader; - const StorageSyncSettingsSection({super.key, this.isDesktop = false}); + const StorageSyncSettingsSection({super.key, this.isDesktop = false, this.showHeader = true}); @override Widget build(BuildContext context) { @@ -46,17 +44,8 @@ class StorageSyncSettingsSection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SettingsSectionHeader(title: 'Storage'), + if (showHeader) const SettingsSectionHeader(title: 'Storage'), const SettingsRow(label: 'Library', value: 'Stored on this device'), - Padding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.xs), - child: Text( - 'Nothing is sent to Papyrus servers while offline mode is on.', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - ), SettingsRow( label: 'Backup', value: 'Export or import a backup', @@ -70,14 +59,14 @@ class StorageSyncSettingsSection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SettingsSectionHeader(title: 'Storage'), + if (showHeader) const SettingsSectionHeader(title: 'Storage'), SettingsRow( label: 'Data sync', value: controller.dataSyncLabel, onTap: () => _showManageSyncServersSheet(context), ), SettingsRow(label: 'Current status', value: controller.statusLabel), - SettingsRow(label: 'File storage', value: controller.fileStorageLabel), + _buildStorageUsage(context, controller), if (controller.hasFailedMediaUploads) SettingsRow( label: 'Media uploads', @@ -111,7 +100,6 @@ class StorageSyncSettingsSection extends StatelessWidget { children: [ _buildInfoRow(context, label: 'Active server', value: controller.dataSyncLabel), _buildInfoRow(context, label: 'Status', value: controller.statusLabel), - _buildInfoRow(context, label: 'File storage', value: controller.fileStorageLabel), if (controller.hasFailedMediaUploads) _buildInfoRow(context, label: 'Media uploads', value: controller.failedMediaUploadLabel), const SizedBox(height: Spacing.sm), @@ -122,49 +110,83 @@ class StorageSyncSettingsSection extends StatelessWidget { style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), ), ), - const SizedBox(height: Spacing.sm), - Wrap( - spacing: Spacing.sm, - runSpacing: Spacing.sm, - alignment: WrapAlignment.start, - children: [ - if (controller.canReconnect) - OutlinedButton.icon( - onPressed: () => _handleReconnectSync(context), - icon: const Icon(Icons.sync, size: IconSizes.small), - label: const Text('Reconnect'), - ), - OutlinedButton.icon( - onPressed: () => _showManageSyncServersSheet(context), - icon: const Icon(Icons.dns_outlined, size: IconSizes.small), - label: const Text('Manage servers'), - ), - if (controller.hasFailedMediaUploads) - OutlinedButton.icon( - onPressed: () => _retryFailedMediaUploads(context), - icon: const Icon(Icons.refresh, size: IconSizes.small), - label: const Text('Retry uploads'), - ), - if (controller.canClearAuthenticatedCache) + const SizedBox(height: Spacing.md), + _buildStorageUsage(context, controller), + const SizedBox(height: Spacing.md), + OutlinedButtonTheme( + data: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, ComponentSizes.buttonHeightMobile), + padding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm), + ).merge(Theme.of(context).outlinedButtonTheme.style), + ), + child: Wrap( + spacing: Spacing.sm, + runSpacing: Spacing.sm, + alignment: WrapAlignment.start, + children: [ + if (controller.canReconnect) + OutlinedButton.icon( + onPressed: () => _handleReconnectSync(context), + icon: const Icon(Icons.sync, size: IconSizes.small), + label: const Text('Reconnect'), + ), OutlinedButton.icon( - onPressed: () => _confirmClearAuthenticatedCache(context), - icon: const Icon(Icons.cleaning_services_outlined, size: IconSizes.small), - label: const Text('Clear local copy'), + onPressed: () => _showManageSyncServersSheet(context), + icon: const Icon(Icons.dns_outlined, size: IconSizes.small), + label: const Text('Manage servers'), ), - ], + if (controller.hasFailedMediaUploads) + OutlinedButton.icon( + onPressed: () => _retryFailedMediaUploads(context), + icon: const Icon(Icons.refresh, size: IconSizes.small), + label: const Text('Retry uploads'), + ), + if (controller.canClearAuthenticatedCache) + OutlinedButton.icon( + onPressed: () => _confirmClearAuthenticatedCache(context), + icon: const Icon(Icons.cleaning_services_outlined, size: IconSizes.small), + label: const Text('Clear local copy'), + ), + ], + ), ), ], ), - const SizedBox(height: Spacing.lg), - SettingsCard( - key: const Key('profile-acquisition-card'), - title: 'Acquisition', - children: [_buildAcquisitionDescription(context), ..._buildAcquisitionSettings(context)], - ), ], ); } + Widget _buildStorageUsage(BuildContext context, StorageSyncController controller) { + final theme = Theme.of(context); + final fraction = controller.fileStorageFraction; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('File storage', style: theme.textTheme.bodyMedium), + if (fraction != null) ...[ + const SizedBox(height: Spacing.sm), + LinearProgressIndicator( + value: fraction, + minHeight: Spacing.sm, + color: theme.colorScheme.primary, + backgroundColor: theme.colorScheme.surfaceContainerHighest, + semanticsLabel: 'File storage', + ), + ], + const SizedBox(height: Spacing.sm), + Text( + controller.fileStorageLabel, + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ], + ), + ); + } + Widget _buildOfflineStorageSyncContent(BuildContext context) { final textTheme = Theme.of(context).textTheme; final mutedStyle = textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant); @@ -180,7 +202,7 @@ class StorageSyncSettingsSection extends StatelessWidget { Text('Your library is stored on this device.', style: textTheme.bodyLarge), const SizedBox(height: Spacing.sm), Text( - 'Nothing is sent to Papyrus servers while offline mode is on. Export a backup before changing devices or clearing app data.', + 'Export a backup before changing devices or clearing app data.', style: mutedStyle, ), ], @@ -212,77 +234,6 @@ class StorageSyncSettingsSection extends StatelessWidget { ); } - static Widget buildMobileAcquisitionSection(BuildContext context) { - final auth = context.watch(); - - if (auth.isOfflineMode) { - return const SizedBox.shrink(); - } - - return Column( - key: const Key('profile-acquisition-section'), - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SettingsSectionHeader(title: 'Acquisition'), - _buildAcquisitionDescription(context), - ..._buildAcquisitionSettings(context), - ], - ); - } - - static Widget _buildAcquisitionDescription(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.xs), - child: Text( - 'Search external sources and send releases to your connected clients.', - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - ); - } - - static List _buildAcquisitionSettings(BuildContext context) { - final prefs = context.watch(); - final availability = context.watch(); - final acquisitionAvailable = availability.state == AcquisitionAvailabilityState.available; - - return [ - MergeSemantics( - child: Semantics( - label: 'Enable acquisition', - enabled: true, - toggled: prefs.acquisitionEnabled, - onTap: () => prefs.acquisitionEnabled = !prefs.acquisitionEnabled, - child: ExcludeSemantics( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => prefs.acquisitionEnabled = !prefs.acquisitionEnabled, - child: SettingsToggleRow( - label: 'Enable acquisition', - value: prefs.acquisitionEnabled, - onChanged: (value) => prefs.acquisitionEnabled = value, - ), - ), - ), - ), - ), - SettingsRow( - label: 'Server support', - value: _acquisitionAvailabilityLabel(availability.state), - showChevron: false, - ), - if (prefs.acquisitionEnabled && acquisitionAvailable) - SettingsRow(label: 'Manage integrations', onTap: () => context.push('/acquisition')), - ]; - } - - static String _acquisitionAvailabilityLabel(AcquisitionAvailabilityState state) { - return switch (state) { - AcquisitionAvailabilityState.available => 'Available on this server', - AcquisitionAvailabilityState.unavailable => 'Unavailable on this server', - AcquisitionAvailabilityState.unknown || AcquisitionAvailabilityState.loading => 'Checking server support…', - }; - } - StorageSyncController _storageSyncController(BuildContext context) { return StorageSyncController( authProvider: context.watch(), diff --git a/app/lib/widgets/settings/settings_row.dart b/app/lib/widgets/settings/settings_row.dart index f3578838..56056604 100644 --- a/app/lib/widgets/settings/settings_row.dart +++ b/app/lib/widgets/settings/settings_row.dart @@ -164,6 +164,8 @@ class SettingsToggleRow extends StatelessWidget { enabled: enabled, builder: (focusNode) => InkWell( hoverColor: Colors.transparent, + splashFactory: NoSplash.splashFactory, + highlightColor: Colors.transparent, focusNode: focusNode, canRequestFocus: enabled, onTap: toggle, 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/shelves/shelf_card.dart b/app/lib/widgets/shelves/shelf_card.dart index b6964f53..324c1388 100644 --- a/app/lib/widgets/shelves/shelf_card.dart +++ b/app/lib/widgets/shelves/shelf_card.dart @@ -59,6 +59,7 @@ class _ShelfCardState extends State { margin: EdgeInsets.zero, clipBehavior: Clip.antiAlias, elevation: AppElevation.level1, + shadowColor: colorScheme.brightness == Brightness.dark ? colorScheme.outline : null, child: InkWell( onTap: widget.onTap, onLongPress: widget.onLongPress, diff --git a/app/lib/widgets/statistics/reading_charts.dart b/app/lib/widgets/statistics/reading_charts.dart index af02018f..67c38c2a 100644 --- a/app/lib/widgets/statistics/reading_charts.dart +++ b/app/lib/widgets/statistics/reading_charts.dart @@ -8,80 +8,14 @@ import 'package:papyrus/providers/statistics_provider.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/themes/app_motion.dart'; -/// A bucket of aggregated daily activities (e.g., a week). -class _AggregatedBucket { - final DateTime startDate; - final DateTime endDate; - final int totalMinutes; - final int totalPages; - final int dayCount; - - const _AggregatedBucket({ - required this.startDate, - required this.endDate, - required this.totalMinutes, - required this.totalPages, - required this.dayCount, - }); - - bool get containsToday { - final now = DateTime.now(); - final today = DateTime(now.year, now.month, now.day); - return !today.isBefore(startDate) && !today.isAfter(endDate); - } - - String get label { - if (startDate.month == endDate.month) { - return '${_months[startDate.month - 1]} ${startDate.day}-${endDate.day}'; - } - - return '${_months[startDate.month - 1]} ${startDate.day} - ${_months[endDate.month - 1]} ${endDate.day}'; - } -} - -/// Aggregates daily activities into weekly buckets. -List<_AggregatedBucket> _aggregateWeekly(List activities) { - if (activities.isEmpty) { - return []; - } - - final buckets = <_AggregatedBucket>[]; - var bucketStart = 0; - - while (bucketStart < activities.length) { - var bucketEnd = bucketStart; - - while (bucketEnd + 1 < activities.length) { - final nextDate = activities[bucketEnd + 1].date; - - if (nextDate.weekday == DateTime.monday && bucketEnd > bucketStart) { - break; - } +double _readingMinutes(DailyActivity activity) => + activity is StatisticsActivity ? activity.minutes : activity.readingMinutes.toDouble(); - if (bucketEnd - bucketStart >= 6) { - break; - } +double _pagesRead(DailyActivity activity) => + activity is StatisticsActivity ? activity.pages : activity.pagesRead.toDouble(); - bucketEnd++; - } - - final slice = activities.sublist(bucketStart, bucketEnd + 1); - - buckets.add( - _AggregatedBucket( - startDate: slice.first.date, - endDate: slice.last.date, - totalMinutes: slice.fold(0, (total, item) => total + item.readingMinutes), - totalPages: slice.fold(0, (total, item) => total + item.pagesRead), - dayCount: slice.length, - ), - ); - - bucketStart = bucketEnd + 1; - } - - return buckets; -} +String _activityLabel(DailyActivity activity) => + activity is StatisticsActivity ? activity.intervalLabel : activity.dayName; const _months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; @@ -91,8 +25,12 @@ String _formatMinutesLabel(double value) { return '0'; } + if (value < 1) { + return '${(value * 60).round()}s'; + } + if (value < 60) { - return '${value.round()}m'; + return value == value.roundToDouble() ? '${value.round()}m' : '${value.toStringAsFixed(1)}m'; } final hours = value / 60; @@ -114,13 +52,33 @@ String _formatPagesLabel(double value) { } /// Computes a nice Y-axis interval and maxY that align cleanly. -({double interval, double maxY}) _niceYAxis(double maxValue) { +({double interval, double maxY}) _niceYAxis(double maxValue, {bool minutes = false}) { if (maxValue <= 0) { - return (interval: 15.0, maxY: 60.0); + return (interval: 1.0, maxY: 4.0); } - final rawInterval = maxValue / 3; - const niceSteps = [5, 10, 15, 20, 25, 30, 50, 60, 100, 120, 150, 200, 250, 300, 500, 1000]; + final rawInterval = maxValue / 4; + final niceSteps = [ + if (minutes) ...[1 / 60, 5 / 60, 10 / 60, 15 / 60, 30 / 60], + 1, + 2, + 5, + 10, + 15, + 20, + 25, + 30, + 50, + 60, + 100, + 120, + 150, + 200, + 250, + 300, + 500, + 1000, + ]; var interval = (rawInterval / 100).ceil() * 100.0; for (final step in niceSteps) { @@ -130,7 +88,7 @@ String _formatPagesLabel(double value) { } } - final maxY = (maxValue / interval).ceil() * interval; + final maxY = math.max(3, (maxValue / interval).ceil()) * interval; return (interval: interval, maxY: maxY); } @@ -140,94 +98,118 @@ Widget _buildYAxisLabel(double value, TextTheme textTheme, ColorScheme colorSche padding: const EdgeInsets.only(right: 4), child: Text( formatter(value), - style: textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant, fontSize: 10), + style: textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant), ), ); } -/// Builds a bottom axis label for chart data points. -Widget _buildBottomLabel(double value, List activities, TextTheme textTheme, ColorScheme colorScheme) { - final idx = value.toInt(); - - if (idx < 0 || idx >= activities.length) { - return const SizedBox.shrink(); +String _activityAxisLabel(DailyActivity activity, int index, int count) { + if (activity is StatisticsActivity && activity.bucketSize != StatisticsBucketSize.day) { + return activity.axisLabel; } - final activity = activities[idx]; - final count = activities.length; - String? label; - if (count <= 7) { - label = activity.dayInitial; - } else if (count <= 31) { - final day = activity.date.day; - final interval = count > 14 ? 5 : 3; + return activity.dayLabel; + } - if (day == 1 || day % interval == 0 || idx == count - 1) { - label = '${activity.date.day}'; - } - } else { - if (idx == 0 || activities[idx].date.month != activities[idx - 1].date.month) { - label = _months[activity.date.month - 1]; - } + if (index == 0 || index == count - 1 || activity.date.day == 1) { + return '${_months[activity.date.month - 1]} ${activity.date.day}'; } - if (label == null) { - return const SizedBox.shrink(); + return '${activity.date.day}'; +} + +int _bottomLabelStep(BuildContext context, double width, List activities) { + final textScaler = MediaQuery.textScalerOf(context); + final availableWidth = math.max(1.0, width - textScaler.scale(60)); + final style = Theme.of(context).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.bold); + var labelWidth = 0.0; + + for (var index = 0; index < activities.length; index++) { + final painter = TextPainter( + text: TextSpan(text: _activityAxisLabel(activities[index], index, activities.length), style: style), + textDirection: Directionality.of(context), + textScaler: textScaler, + )..layout(); + + labelWidth = math.max(labelWidth, painter.width); + painter.dispose(); } - return Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - label, - style: textTheme.labelSmall?.copyWith( - color: activity.isToday ? colorScheme.primary : colorScheme.onSurfaceVariant, - fontWeight: activity.isToday ? FontWeight.bold : FontWeight.normal, - fontSize: 10, - ), - ), - ); + final bucketWidth = availableWidth / math.max(1, activities.length); + final step = math.max(1, ((labelWidth + 12) / bucketWidth).ceil()); + return step; } -/// Builds a bottom axis label for aggregated weekly buckets. -Widget _buildBucketBottomLabel( +/// Builds a bottom axis label for chart data points. +Widget _buildBottomLabel( double value, - List<_AggregatedBucket> buckets, + List activities, TextTheme textTheme, - ColorScheme colorScheme, -) { - final idx = value.toInt(); + ColorScheme colorScheme, { + int labelStep = 1, +}) { + final index = value.toInt(); - if (idx < 0 || idx >= buckets.length) { + if (index < 0 || index >= activities.length || value != index.toDouble()) { return const SizedBox.shrink(); } - final bucket = buckets[idx]; - - if (idx > 0 && buckets[idx].startDate.month == buckets[idx - 1].startDate.month) { - return const SizedBox.shrink(); - } - - return Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - _months[bucket.startDate.month - 1], - style: textTheme.labelSmall?.copyWith( - color: bucket.containsToday ? colorScheme.primary : colorScheme.onSurfaceVariant, - fontWeight: bucket.containsToday ? FontWeight.bold : FontWeight.normal, - fontSize: 10, + final count = activities.length; + final showLabel = (count - 1 - index) % labelStep == 0; + final activity = activities[index]; + final label = _activityAxisLabel(activity, index, count); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + key: ValueKey('activity-axis-tick-$index'), + width: 1, + height: 4, + color: colorScheme.outline, ), - ), + if (showLabel) ...[ + const SizedBox(height: 4), + Text( + label, + style: textTheme.labelSmall?.copyWith( + color: activity.isToday ? colorScheme.primary : colorScheme.onSurfaceVariant, + fontWeight: activity.isToday ? FontWeight.bold : FontWeight.normal, + ), + ), + ], + ], ); } -/// A bar chart displaying reading time data using fl_chart. -class ReadingTimeBarChart extends StatelessWidget { +enum ReadingActivityMetric { time, pages } + +/// Shared plotting and axes keep Time and Pages visually consistent. +class ReadingActivityBarChart extends StatelessWidget { final List activities; - final bool isWeekly; + final ReadingActivityMetric metric; final bool isDesktop; - const ReadingTimeBarChart({super.key, required this.activities, this.isWeekly = true, this.isDesktop = false}); + const ReadingActivityBarChart({ + super.key, + required this.activities, + this.metric = ReadingActivityMetric.time, + this.isDesktop = false, + }); + + double _value(DailyActivity activity) => + metric == ReadingActivityMetric.time ? _readingMinutes(activity) : _pagesRead(activity); + + String _valueLabel(DailyActivity activity) { + if (metric == ReadingActivityMetric.time) { + return activity.readingTimeLabel; + } + + final pages = _pagesRead(activity); + final count = pages == pages.roundToDouble() ? '${pages.round()}' : pages.toStringAsFixed(1); + return '$count ${pages == 1 ? 'page' : 'pages'}'; + } @override Widget build(BuildContext context) { @@ -235,32 +217,26 @@ class ReadingTimeBarChart extends StatelessWidget { return _buildEmptyState(context); } - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - final chartHeight = isDesktop ? 200.0 : 160.0; - - if (activities.length > 21) { - return _buildAggregatedChart(context, colorScheme, textTheme, chartHeight); - } - - return _buildDailyChart(context, colorScheme, textTheme, chartHeight); + return _buildDailyChart(context, Theme.of(context).colorScheme, Theme.of(context).textTheme, isDesktop ? 200 : 160); } Widget _buildDailyChart(BuildContext context, ColorScheme colorScheme, TextTheme textTheme, double chartHeight) { - final maxMinutes = activities.maxMinutes.toDouble(); - final yAxis = _niceYAxis(maxMinutes); + final maxValue = activities.map(_value).reduce(math.max); + final yAxis = _niceYAxis(maxValue, minutes: metric == ReadingActivityMetric.time); return SizedBox( height: chartHeight, child: LayoutBuilder( builder: (context, constraints) { - final availableWidth = constraints.maxWidth - 60; + final availableWidth = constraints.maxWidth - MediaQuery.textScalerOf(context).scale(60); final barWidth = (availableWidth / activities.length * 0.6).clamp(4.0, isDesktop ? 20.0 : 16.0); + final labelStep = _bottomLabelStep(context, constraints.maxWidth, activities); return BarChart( - key: ValueKey(AppMotion.disabled(context)), + key: ValueKey((AppMotion.disabled(context), metric)), BarChartData( alignment: BarChartAlignment.spaceAround, + minY: 0, maxY: yAxis.maxY, barTouchData: BarTouchData( enabled: true, @@ -271,7 +247,7 @@ class ReadingTimeBarChart extends StatelessWidget { final activity = activities[groupIndex]; return BarTooltipItem( - '${activity.dayName}\n${activity.readingTimeLabel}', + '${_activityLabel(activity)}\n${_valueLabel(activity)}', textTheme.bodySmall!.copyWith(color: colorScheme.onInverseSurface), ); }, @@ -284,17 +260,28 @@ class ReadingTimeBarChart extends StatelessWidget { bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, - getTitlesWidget: (value, meta) => _buildBottomLabel(value, activities, textTheme, colorScheme), - reservedSize: 28, + interval: 1, + getTitlesWidget: (value, meta) => _buildBottomLabel( + value, + activities, + textTheme, + colorScheme, + labelStep: labelStep, + ), + reservedSize: MediaQuery.textScalerOf(context).scale(28), ), ), leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, - reservedSize: 40, + reservedSize: MediaQuery.textScalerOf(context).scale(40), interval: yAxis.interval, - getTitlesWidget: (value, meta) => - _buildYAxisLabel(value, textTheme, colorScheme, _formatMinutesLabel), + getTitlesWidget: (value, meta) => _buildYAxisLabel( + value, + textTheme, + colorScheme, + metric == ReadingActivityMetric.time ? _formatMinutesLabel : _formatPagesLabel, + ), ), ), ), @@ -303,115 +290,25 @@ class ReadingTimeBarChart extends StatelessWidget { drawVerticalLine: false, horizontalInterval: yAxis.interval, getDrawingHorizontalLine: (value) => - FlLine(color: colorScheme.outlineVariant.withValues(alpha: 0.5), strokeWidth: 1), + FlLine(color: colorScheme.outline.withValues(alpha: 0.7), strokeWidth: 1), ), - borderData: FlBorderData(show: false), - barGroups: activities.asMap().entries.map((entry) { - final index = entry.key; - final activity = entry.value; - - return BarChartGroupData( - x: index, - barRods: [ - BarChartRodData( - toY: activity.readingMinutes.toDouble(), - color: activity.isToday ? colorScheme.primary : colorScheme.primary.withValues(alpha: 0.6), - width: barWidth, - borderRadius: const BorderRadius.vertical(top: Radius.circular(4)), - ), - ], - ); - }).toList(), - ), - duration: AppMotion.duration(context, AnimationDurations.standard), - ); - }, - ), - ); - } - - Widget _buildAggregatedChart(BuildContext context, ColorScheme colorScheme, TextTheme textTheme, double chartHeight) { - final buckets = _aggregateWeekly(activities); - - if (buckets.isEmpty) { - return _buildEmptyState(context); - } - - final maxMinutes = buckets - .map((item) => item.totalMinutes) - .reduce((total, value) => math.max(total, value)) - .toDouble(); - - final yAxis = _niceYAxis(maxMinutes); - - return SizedBox( - height: chartHeight, - child: LayoutBuilder( - builder: (context, constraints) { - final availableWidth = constraints.maxWidth - 60; - final barWidth = (availableWidth / buckets.length * 0.6).clamp(6.0, isDesktop ? 24.0 : 18.0); - - return BarChart( - key: ValueKey(AppMotion.disabled(context)), - BarChartData( - alignment: BarChartAlignment.spaceAround, - maxY: yAxis.maxY, - barTouchData: BarTouchData( - enabled: true, - touchTooltipData: BarTouchTooltipData( - getTooltipColor: (group) => colorScheme.inverseSurface, - tooltipRoundedRadius: AppRadius.md, - getTooltipItem: (group, groupIndex, rod, rodIndex) { - final bucket = buckets[groupIndex]; - final hours = bucket.totalMinutes / 60; - final timeLabel = hours >= 1 ? '${hours.toStringAsFixed(1)}h' : '${bucket.totalMinutes}m'; - - return BarTooltipItem( - '${bucket.label}\n$timeLabel total', - textTheme.bodySmall!.copyWith(color: colorScheme.onInverseSurface), - ); - }, - ), - ), - titlesData: FlTitlesData( + borderData: FlBorderData( show: true, - rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - getTitlesWidget: (value, meta) => _buildBucketBottomLabel(value, buckets, textTheme, colorScheme), - reservedSize: 28, - ), - ), - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 40, - interval: yAxis.interval, - getTitlesWidget: (value, meta) => - _buildYAxisLabel(value, textTheme, colorScheme, _formatMinutesLabel), - ), + border: Border( + top: BorderSide(color: colorScheme.outline.withValues(alpha: 0.7)), + bottom: BorderSide(color: colorScheme.outline), ), ), - gridData: FlGridData( - show: true, - drawVerticalLine: false, - horizontalInterval: yAxis.interval, - getDrawingHorizontalLine: (value) => - FlLine(color: colorScheme.outlineVariant.withValues(alpha: 0.5), strokeWidth: 1), - ), - borderData: FlBorderData(show: false), - barGroups: buckets.asMap().entries.map((entry) { + barGroups: activities.asMap().entries.map((entry) { final index = entry.key; - final bucket = entry.value; + final activity = entry.value; return BarChartGroupData( x: index, barRods: [ BarChartRodData( - toY: bucket.totalMinutes.toDouble(), - color: bucket.containsToday ? colorScheme.primary : colorScheme.primary.withValues(alpha: 0.6), + toY: _value(activity), + color: activity.isToday ? colorScheme.primary : colorScheme.primary.withValues(alpha: 0.6), width: barWidth, borderRadius: const BorderRadius.vertical(top: Radius.circular(4)), ), @@ -434,231 +331,6 @@ class ReadingTimeBarChart extends StatelessWidget { } } -/// A line chart displaying pages read trends using fl_chart. -class PagesReadLineChart extends StatelessWidget { - final List activities; - final bool isWeekly; - final bool isDesktop; - - const PagesReadLineChart({super.key, required this.activities, this.isWeekly = true, this.isDesktop = false}); - - @override - Widget build(BuildContext context) { - if (activities.isEmpty) { - return _buildEmptyState(context); - } - - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - final chartHeight = isDesktop ? 200.0 : 160.0; - - if (activities.length > 21) { - return _buildAggregatedChart(context, colorScheme, textTheme, chartHeight); - } - - return _buildDailyChart(context, colorScheme, textTheme, chartHeight); - } - - Widget _buildDailyChart(BuildContext context, ColorScheme colorScheme, TextTheme textTheme, double chartHeight) { - final maxPages = activities - .map((item) => item.pagesRead) - .reduce((total, value) => math.max(total, value)) - .toDouble(); - - final yAxis = _niceYAxis(maxPages); - - final spots = activities.asMap().entries.map((entry) { - return FlSpot(entry.key.toDouble(), entry.value.pagesRead.toDouble()); - }).toList(); - - final showDots = activities.length <= 14; - - return SizedBox( - height: chartHeight, - child: LineChart( - key: ValueKey(AppMotion.disabled(context)), - LineChartData( - minY: 0, - maxY: yAxis.maxY, - lineTouchData: LineTouchData( - enabled: true, - touchTooltipData: LineTouchTooltipData( - getTooltipColor: (spot) => colorScheme.inverseSurface, - tooltipRoundedRadius: AppRadius.md, - getTooltipItems: (touchedSpots) { - return touchedSpots.map((spot) { - final activity = activities[spot.x.toInt()]; - - return LineTooltipItem( - '${activity.dayName}\n${activity.pagesRead} pages', - textTheme.bodySmall!.copyWith(color: colorScheme.onInverseSurface), - ); - }).toList(); - }, - ), - ), - titlesData: FlTitlesData( - show: true, - rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - interval: 1, - getTitlesWidget: (value, meta) => _buildBottomLabel(value, activities, textTheme, colorScheme), - reservedSize: 28, - ), - ), - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 40, - interval: yAxis.interval, - getTitlesWidget: (value, meta) => _buildYAxisLabel(value, textTheme, colorScheme, _formatPagesLabel), - ), - ), - ), - gridData: FlGridData( - show: true, - drawVerticalLine: false, - horizontalInterval: yAxis.interval, - getDrawingHorizontalLine: (value) => - FlLine(color: colorScheme.outlineVariant.withValues(alpha: 0.5), strokeWidth: 1), - ), - borderData: FlBorderData(show: false), - lineBarsData: [ - LineChartBarData( - spots: spots, - isCurved: true, - curveSmoothness: 0.3, - color: colorScheme.tertiary, - barWidth: 2.5, - isStrokeCapRound: true, - dotData: FlDotData( - show: showDots, - getDotPainter: (spot, percent, barData, index) { - return FlDotCirclePainter( - radius: 3, - color: colorScheme.tertiary, - strokeWidth: 2, - strokeColor: colorScheme.surface, - ); - }, - ), - belowBarData: BarAreaData(show: true, color: colorScheme.tertiary.withValues(alpha: 0.1)), - ), - ], - ), - duration: AppMotion.duration(context, AnimationDurations.standard), - ), - ); - } - - Widget _buildAggregatedChart(BuildContext context, ColorScheme colorScheme, TextTheme textTheme, double chartHeight) { - final buckets = _aggregateWeekly(activities); - - if (buckets.isEmpty) { - return _buildEmptyState(context); - } - - final maxPages = buckets.map((item) => item.totalPages).reduce((total, value) => math.max(total, value)).toDouble(); - final yAxis = _niceYAxis(maxPages); - - final spots = buckets.asMap().entries.map((entry) { - return FlSpot(entry.key.toDouble(), entry.value.totalPages.toDouble()); - }).toList(); - - final showDots = buckets.length <= 14; - - return SizedBox( - height: chartHeight, - child: LineChart( - key: ValueKey(AppMotion.disabled(context)), - LineChartData( - minY: 0, - maxY: yAxis.maxY, - lineTouchData: LineTouchData( - enabled: true, - touchTooltipData: LineTouchTooltipData( - getTooltipColor: (spot) => colorScheme.inverseSurface, - tooltipRoundedRadius: AppRadius.md, - getTooltipItems: (touchedSpots) { - return touchedSpots.map((spot) { - final bucket = buckets[spot.x.toInt()]; - - return LineTooltipItem( - '${bucket.label}\n${bucket.totalPages} pages', - textTheme.bodySmall!.copyWith(color: colorScheme.onInverseSurface), - ); - }).toList(); - }, - ), - ), - titlesData: FlTitlesData( - show: true, - rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - interval: 1, - getTitlesWidget: (value, meta) => _buildBucketBottomLabel(value, buckets, textTheme, colorScheme), - reservedSize: 28, - ), - ), - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 40, - interval: yAxis.interval, - getTitlesWidget: (value, meta) => _buildYAxisLabel(value, textTheme, colorScheme, _formatPagesLabel), - ), - ), - ), - gridData: FlGridData( - show: true, - drawVerticalLine: false, - horizontalInterval: yAxis.interval, - getDrawingHorizontalLine: (value) => - FlLine(color: colorScheme.outlineVariant.withValues(alpha: 0.5), strokeWidth: 1), - ), - borderData: FlBorderData(show: false), - lineBarsData: [ - LineChartBarData( - spots: spots, - isCurved: true, - curveSmoothness: 0.3, - color: colorScheme.tertiary, - barWidth: 2.5, - isStrokeCapRound: true, - dotData: FlDotData( - show: showDots, - getDotPainter: (spot, percent, barData, index) { - return FlDotCirclePainter( - radius: 3, - color: colorScheme.tertiary, - strokeWidth: 2, - strokeColor: colorScheme.surface, - ); - }, - ), - belowBarData: BarAreaData(show: true, color: colorScheme.tertiary.withValues(alpha: 0.1)), - ), - ], - ), - duration: AppMotion.duration(context, AnimationDurations.standard), - ), - ); - } - - Widget _buildEmptyState(BuildContext context) { - return SizedBox( - height: isDesktop ? 200 : 160, - child: const EmptyState.compact(icon: Icons.bar_chart_outlined, title: 'No reading data for this period'), - ); - } -} - /// A bar chart displaying books read per month using fl_chart. class BooksPerMonthChart extends StatelessWidget { final List monthlyStats; @@ -682,8 +354,7 @@ class BooksPerMonthChart extends StatelessWidget { final chartHeight = isDesktop ? 200.0 : 160.0; - // Take last 6 months for display - final displayStats = monthlyStats.length > 6 ? monthlyStats.sublist(monthlyStats.length - 6) : monthlyStats; + final displayStats = monthlyStats; return SizedBox( height: chartHeight, @@ -706,7 +377,7 @@ class BooksPerMonthChart extends StatelessWidget { final stats = displayStats[groupIndex]; return BarTooltipItem( - '${stats.fullMonthLabel}\n${stats.booksRead} books', + '${stats.fullMonthLabel} ${stats.year}\n${stats.booksRead} books', textTheme.bodySmall!.copyWith(color: colorScheme.onInverseSurface), ); }, @@ -729,12 +400,12 @@ class BooksPerMonthChart extends StatelessWidget { return Padding( padding: const EdgeInsets.only(top: 8), child: Text( - stats.monthLabel, - style: textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant, fontSize: 10), + '${stats.monthLabel} ${stats.year % 100}', + style: textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant), ), ); }, - reservedSize: 28, + reservedSize: MediaQuery.textScalerOf(context).scale(28), ), ), leftTitles: AxisTitles( @@ -748,7 +419,7 @@ class BooksPerMonthChart extends StatelessWidget { padding: const EdgeInsets.only(right: 4), child: Text( '${value.toInt()}', - style: textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant, fontSize: 10), + style: textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant), ), ); } @@ -763,9 +434,12 @@ class BooksPerMonthChart extends StatelessWidget { drawVerticalLine: false, horizontalInterval: 1, getDrawingHorizontalLine: (value) => - FlLine(color: colorScheme.outlineVariant.withValues(alpha: 0.5), strokeWidth: 1), + FlLine(color: colorScheme.outline.withValues(alpha: 0.7), strokeWidth: 1), + ), + borderData: FlBorderData( + show: true, + border: Border(bottom: BorderSide(color: colorScheme.outline)), ), - borderData: FlBorderData(show: false), barGroups: displayStats.asMap().entries.map((entry) { final index = entry.key; final stats = entry.value; diff --git a/app/lib/widgets/statistics/stat_card.dart b/app/lib/widgets/statistics/stat_card.dart index 932826ed..9d43212c 100644 --- a/app/lib/widgets/statistics/stat_card.dart +++ b/app/lib/widgets/statistics/stat_card.dart @@ -171,6 +171,7 @@ class CompactStatCard extends StatelessWidget { class StatSectionCard extends StatelessWidget { /// The section title. final String title; + final String? subtitle; /// Optional action widget (e.g., button). final Widget? action; @@ -181,28 +182,56 @@ class StatSectionCard extends StatelessWidget { /// Whether to use desktop styling. final bool isDesktop; - const StatSectionCard({super.key, required this.title, this.action, required this.child, this.isDesktop = false}); + const StatSectionCard({ + super.key, + required this.title, + this.action, + this.subtitle, + required this.child, + this.isDesktop = false, + }); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; + final shape = Theme.of(context).cardTheme.shape; + return Container( padding: EdgeInsets.all(isDesktop ? Spacing.lg : Spacing.md), - decoration: BoxDecoration( + decoration: ShapeDecoration( color: colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(AppRadius.xl), - border: Border.all(color: colorScheme.outlineVariant, width: BorderWidths.thin), + shape: (shape is OutlinedBorder ? shape : const RoundedRectangleBorder()).copyWith( + side: BorderSide(color: colorScheme.outlineVariant, width: BorderWidths.thin), + ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Text(title, style: textTheme.titleMedium), - ?action, + Expanded( + flex: 1, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: textTheme.titleMedium), + if (subtitle != null) ...[ + const SizedBox(height: Spacing.xs), + Text(subtitle!, style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)), + ], + ], + ), + ), + if (action != null) ...[ + const SizedBox(width: Spacing.sm), + Expanded( + flex: 1, + child: Align(alignment: AlignmentDirectional.centerEnd, child: action!), + ), + ], ], ), const SizedBox(height: Spacing.md), diff --git a/app/lib/widgets/statistics/statistics_row.dart b/app/lib/widgets/statistics/statistics_row.dart new file mode 100644 index 00000000..9f61dd86 --- /dev/null +++ b/app/lib/widgets/statistics/statistics_row.dart @@ -0,0 +1,72 @@ +import 'dart:math' as math; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:papyrus/themes/design_tokens.dart'; + +/// Equal-height panels whose contents may use LayoutBuilder or scroll views. +/// Measure real layouts instead of requesting unsupported intrinsic dimensions. +class StatisticsRow extends MultiChildRenderObjectWidget { + StatisticsRow({super.key, required Widget main, required Widget aside}) : super(children: [main, aside]); + + @override + RenderObject createRenderObject(BuildContext context) => _RenderStatisticsRow(Directionality.of(context)); + + @override + void updateRenderObject(BuildContext context, covariant RenderObject renderObject) { + (renderObject as _RenderStatisticsRow).textDirection = Directionality.of(context); + } +} + +class _StatisticsParentData extends ContainerBoxParentData {} + +class _RenderStatisticsRow extends RenderBox + with + ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin { + _RenderStatisticsRow(this._textDirection); + + TextDirection _textDirection; + + set textDirection(TextDirection value) { + if (value != _textDirection) { + _textDirection = value; + markNeedsLayout(); + } + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! _StatisticsParentData) { + child.parentData = _StatisticsParentData(); + } + } + + @override + void performLayout() { + final main = firstChild!; + final aside = lastChild!; + final available = constraints.maxWidth - Spacing.lg; + final mainWidth = available * 5 / 8; + final asideWidth = available - mainWidth; + main.layout(BoxConstraints.tightFor(width: mainWidth), parentUsesSize: true); + aside.layout(BoxConstraints.tightFor(width: asideWidth), parentUsesSize: true); + final height = math.max(main.size.height, aside.size.height); + // A minimum height keeps content changes bubbling up during expansion. + // Tight height constraints would make each panel a separate layout boundary. + main.layout(BoxConstraints(minWidth: mainWidth, maxWidth: mainWidth, minHeight: height), parentUsesSize: true); + aside.layout(BoxConstraints(minWidth: asideWidth, maxWidth: asideWidth, minHeight: height), parentUsesSize: true); + final mainData = main.parentData! as ContainerBoxParentData; + final asideData = aside.parentData! as ContainerBoxParentData; + mainData.offset = Offset(_textDirection == TextDirection.ltr ? 0 : asideWidth + Spacing.lg, 0); + asideData.offset = Offset(_textDirection == TextDirection.ltr ? mainWidth + Spacing.lg : 0, 0); + size = constraints.constrain(Size(constraints.maxWidth, height)); + } + + @override + void paint(PaintingContext context, Offset offset) => defaultPaint(context, offset); + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) => + defaultHitTestChildren(result, position: position); +} 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/pubspec.lock b/app/pubspec.lock index 240886b5..2ff7532c 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -579,8 +579,8 @@ packages: dependency: "direct main" description: path: "." - ref: eafa444a5acf0ea51018a88eb76de3d19f76187a - resolved-ref: eafa444a5acf0ea51018a88eb76de3d19f76187a + ref: fa667ae96c64e2d1d80c06e83d326265673abc75 + resolved-ref: fa667ae96c64e2d1d80c06e83d326265673abc75 url: "https://github.com/PapyrusReader/reader.git" source: git version: "0.0.1" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index bce049dd..d4f7e9dd 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -1,7 +1,7 @@ name: papyrus description: "Digital library management application." publish_to: 'none' -version: 0.3.0+7 +version: 0.4.0+8 environment: sdk: ^3.9.2 @@ -27,7 +27,7 @@ dependencies: papyrus_reader: git: url: https://github.com/PapyrusReader/reader.git - ref: eafa444a5acf0ea51018a88eb76de3d19f76187a + ref: fa667ae96c64e2d1d80c06e83d326265673abc75 epub_pro: ^5.6.0 syncfusion_flutter_pdf: ^32.2.3 dart_mobi: ^1.0.2 diff --git a/app/test/folders/connect_folder_flow_test.dart b/app/test/folders/connect_folder_flow_test.dart index ecb07c48..0b6453f6 100644 --- a/app/test/folders/connect_folder_flow_test.dart +++ b/app/test/folders/connect_folder_flow_test.dart @@ -185,9 +185,13 @@ void main() { expect(tester.getRect(toggle), toggleBounds); expect(find.text('16 files cannot be imported'), findsOneWidget); expect(find.text('—'), findsNothing); - expect(tester.widget(import).onPressed, isNull); + expect(tester.widget(import).onPressed, isNotNull); + expect(find.byType(LinearProgressIndicator), findsNothing); } + await tester.pump(const Duration(milliseconds: 200)); + expect(tester.widget(import).onPressed, isNull); + expect(find.byType(LinearProgressIndicator), findsOneWidget); scans.first.complete(FolderPreview()..supported = 999); await tester.pump(); expect(find.text('999'), findsNothing); @@ -204,6 +208,35 @@ void main() { expect(tester.getRect(name), nameBounds); expect(tester.getRect(toggle), toggleBounds); expect(tester.takeException(), isNull); + + await tester.tap(toggle); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + scans.last.complete(FolderPreview()..supported = 2); + await tester.pump(); + expect(find.byType(LinearProgressIndicator), findsNothing); + expect(tester.widget(import).onPressed, isNotNull); + + await tester.tap(toggle); + await tester.pump(); + await tester.tap(import); + await tester.pump(); + expect(folders.connections, 0, reason: 'Import waits for the latest preview.'); + scans.last.completeError(const FolderAccessException('access', 'Folder access denied')); + await tester.pumpAndSettle(); + expect(folders.connections, 0); + expect(find.text('Folder access denied'), findsOneWidget); + expect(tester.widget(import).onPressed, isNull); + + await tester.tap(toggle); + await tester.pump(); + await tester.tap(import); + await tester.pump(); + expect(folders.connections, 0); + scans.last.complete(FolderPreview()..supported = 3); + await tester.pumpAndSettle(); + expect(folders.connections, 1); + expect(find.byType(ConnectFolderSheet), findsNothing); }); } diff --git a/app/test/folders/watched_folders_page_test.dart b/app/test/folders/watched_folders_page_test.dart index 91d3ebbe..d94c4a22 100644 --- a/app/test/folders/watched_folders_page_test.dart +++ b/app/test/folders/watched_folders_page_test.dart @@ -226,6 +226,9 @@ void main() { expect(find.byType(FloatingActionButton), findsNothing); final toolbar = find.byType(size.width < 840 ? LibraryMobileToolbar : LibraryToolbar); final toolbarPosition = tester.getTopLeft(toolbar); + final titleBounds = tester.getRect(find.text('Folders')); + expect(titleBounds.left, size.width < 840 ? 72 : 24); + expect(titleBounds.center.dy, size.width < 840 ? 32 : 48); folders.items.add(WatchedFolder(id: 'folder', grant: grant)); folders.notifyListeners(); await tester.pumpAndSettle(); @@ -234,7 +237,12 @@ void main() { expect(find.byType(Card), findsNothing); expect(find.text('Account upload'), findsNothing); expect(tester.getTopLeft(toolbar), toolbarPosition); + expect(tester.getRect(find.text('Folders')), titleBounds); expect(find.byType(FloatingActionButton), size.width < 840 ? findsOneWidget : findsNothing); + folders.items.clear(); + folders.notifyListeners(); + await tester.pumpAndSettle(); + expect(tester.getRect(find.text('Folders')), titleBounds); expect(tester.takeException(), isNull); }); } @@ -257,6 +265,7 @@ void main() { id: 'archive', grant: const FolderGrant(key: 'archive', identity: 'archive', name: 'Archive', location: '/Archive'), status: FolderStatus.accessNeeded, + error: 'Reconnect this folder to allow access.', ), ]); await mount(tester, size: size, theme: AppTheme.dark); @@ -292,16 +301,56 @@ void main() { } } + if (size.width < 840) { + final row = find.byKey(const ValueKey('folder')); + + for (final (label, value) in [ + ('Status', 'Up to date'), + ('Books', '1'), + ('Destination shelf', 'No shelf'), + ('Last checked', 'Oct 31, 2026 09:30'), + ]) { + final labelFinder = find.descendant(of: row, matching: find.text(label)); + final valueFinder = find.descendant(of: row, matching: find.text(value)); + final gap = tester.getTopLeft(valueFinder).dy - tester.getBottomLeft(labelFinder).dy; + expect(gap, closeTo(4, 1), reason: '$label should use the same label-to-value spacing'); + } + } + await _captureReview(tester, 'folders-${size.width.toInt()}'); await tester.tap(find.text('Reading inbox')); await tester.pumpAndSettle(); await _captureReview(tester, 'details-${size.width.toInt()}'); + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + await tester.tap(find.descendant(of: find.byType(FolderIdentity), matching: find.text('Archive'))); + await tester.pumpAndSettle(); + final recoverySheet = find.byType(FolderDetailsSheet); + final reconnect = find.descendant(of: recoverySheet, matching: find.widgetWithText(FilledButton, 'Reconnect')); + final accessStatus = find.descendant(of: recoverySheet, matching: find.text('Access needed')); + final explanation = find.text('Reconnect this folder to allow access.'); + final messageTop = tester.getTopLeft(accessStatus).dy; + final messageBottom = tester.getBottomRight(explanation).dy; + expect(tester.getCenter(reconnect).dy, closeTo((messageTop + messageBottom) / 2, 1)); + expect(tester.getTopLeft(reconnect).dx, greaterThan(tester.getBottomRight(accessStatus).dx)); + expect(tester.getTopLeft(reconnect).dx, greaterThan(tester.getBottomRight(explanation).dx)); + await _captureReview(tester, 'reconnect-details-${size.width.toInt()}'); + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Reading inbox')); + await tester.pumpAndSettle(); expect(find.text('Stop watching'), findsNothing); expect(find.text('Connection'), findsNothing); expect( - find.descendant(of: find.byType(FolderDetailsSheet), matching: find.text('Destination shelf')), + find.descendant(of: find.byType(FolderDetailsSheet), matching: find.text('Shelf')), findsOneWidget, ); + final details = find.byType(FolderDetailsSheet); + final shelfLabel = find.descendant(of: details, matching: find.text('Shelf')); + final subfoldersLabel = find.descendant(of: details, matching: find.text('Subfolders')); + final checkedLabel = find.descendant(of: details, matching: find.text('Last checked')); + expect(tester.getTopLeft(shelfLabel).dy, tester.getTopLeft(subfoldersLabel).dy); + expect(tester.getTopLeft(shelfLabel).dy, tester.getTopLeft(checkedLabel).dy); expect(find.text('Close').hitTestable(), findsOneWidget); expect(tester.getSize(find.byType(FolderDetailsSheet)).height, lessThan(size.height * .85)); final viewBooks = find.descendant(of: find.byType(FolderDetailsSheet), matching: find.text('View 1 book')); @@ -383,7 +432,7 @@ void main() { }); for (final size in [const Size(390, 844), const Size(1024, 800)]) { - testWidgets('progress reserves row and sheet space and displays remaining books at $size', (tester) async { + testWidgets('progress uses content height and keeps detail actions stable at $size', (tester) async { final folder = WatchedFolder(id: 'folder', grant: grant); folders.items.addAll([ folder, @@ -395,6 +444,7 @@ void main() { await mount(tester, size: size, theme: AppTheme.dark); final nextRow = find.byKey(const ValueKey('second')); final position = tester.getTopLeft(nextRow); + final statusHeight = tester.getSize(find.text('Up to date').first).height; folder.status = FolderStatus.scanning; folders.notifyListeners(); await tester.pump(); @@ -404,8 +454,10 @@ void main() { folder.progress = FolderImportProgress(total: 10000)..processed = 2500; folders.notifyListeners(); await tester.pump(); - expect(tester.getTopLeft(nextRow), position); - expect(find.text('2500/10000\n7500 left'), findsOneWidget); + final progressLabel = find.text('2500/10000\n7500 left'); + expect(progressLabel, findsOneWidget); + final addedHeight = size.width < 840 ? tester.getSize(progressLabel).height - statusHeight : 0.0; + expect(tester.getTopLeft(nextRow).dy, closeTo(position.dy + addedHeight, 1)); expect(tester.widget(find.byType(LinearProgressIndicator)).value, .25); await _captureReview(tester, 'import-progress-${size.width.toInt()}'); folder.status = FolderStatus.upToDate; @@ -444,12 +496,21 @@ void main() { (const Size(820, 900), 1.0), (const Size(1200, 900), 1.3), ]) { - testWidgets('folder row height stays stable across all statuses at $size and scale $scale', (tester) async { - final folder = WatchedFolder(id: 'folder', grant: grant, status: FolderStatus.paused); + testWidgets('folder actions stay in the header without shifting content at $size and scale $scale', (tester) async { + final folder = WatchedFolder( + id: 'folder', + grant: grant, + status: FolderStatus.paused, + lastScan: DateTime(2026, 11, 1, 23, 59), + ); folders.items.add(folder); await mount(tester, size: size, textScale: scale, theme: AppTheme.dark); final row = find.byKey(const ValueKey('folder')); final bounds = tester.getRect(row); + final identity = find.descendant(of: row, matching: find.byType(FolderIdentity)); + final identityBounds = tester.getRect(identity); + final statusTitle = find.descendant(of: row, matching: find.text('Status')); + final statusTitlePosition = size.width < 1200 ? tester.getTopLeft(statusTitle) : null; for (final status in FolderStatus.values) { for (final shelfMissing in [false, true]) { @@ -461,14 +522,28 @@ void main() { : null; folders.notifyListeners(); await tester.pump(); - expect(tester.getRect(row), bounds, reason: '$status, missing shelf: $shelfMissing'); + expect(tester.getRect(identity), identityBounds); + + if (size.width >= 1200) { + expect(tester.getRect(row), bounds, reason: '$status, missing shelf: $shelfMissing'); + } else { + expect(tester.getTopLeft(statusTitle), statusTitlePosition); + } + + final action = find.descendant(of: row, matching: find.byType(FilledButton)); + + if (action.evaluate().isNotEmpty && size.width < 1200) { + final actionBounds = tester.getRect(action); + expect(actionBounds.left, greaterThanOrEqualTo(identityBounds.right)); + expect(actionBounds.center.dy, closeTo(identityBounds.center.dy, 1)); + } + expect(tester.takeException(), isNull); } } folder.status = FolderStatus.paused; folder.shelfId = null; - folder.lastScan = null; folder.progress = null; folders.notifyListeners(); await tester.pumpAndSettle(); 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/opds/catalogs_page_test.dart b/app/test/opds/catalogs_page_test.dart index 9939188d..37aae2e9 100644 --- a/app/test/opds/catalogs_page_test.dart +++ b/app/test/opds/catalogs_page_test.dart @@ -658,13 +658,16 @@ void main() { }); } - testWidgets('mobile catalog home keeps menu, Downloads and FAB without a title header', (tester) async { + testWidgets('mobile catalog home shows its title alongside menu, Downloads and FAB', (tester) async { await _CatalogPageHarness.mount(tester, (_) => _feedResponse('Books'), initialLocation: '/library/catalogs'); tester.view.physicalSize = const Size(424, 951); await _settleNetwork(tester); expect(find.byType(FloatingActionButton), findsOneWidget); expect(find.widgetWithText(FilledButton, 'Add catalog'), findsNothing); - expect(find.text('Catalogs'), findsNothing); + expect(find.text('Catalogs'), findsOneWidget); + final titleBounds = tester.getRect(find.text('Catalogs')); + expect(titleBounds.left, 72); + expect(titleBounds.center.dy, 32); expect(find.byType(LibraryPageHeader), findsNothing); expect(tester.getTopLeft(find.byTooltip('Library sections')).dy, 8); expect(tester.getCenter(find.byTooltip('Downloads')).dy, tester.getCenter(find.byTooltip('Library sections')).dy); @@ -1354,7 +1357,10 @@ void main() { expect(find.text('No catalogs yet'), findsOneWidget); if (width >= 840) { - expect(find.text('Catalogs'), findsNothing); + expect(find.text('Catalogs'), findsOneWidget); + final titleBounds = tester.getRect(find.text('Catalogs')); + expect(titleBounds.left, 24); + expect(titleBounds.center.dy, 48); expect(find.byKey(const Key('catalog-header-divider')), findsNothing); expect(find.byType(LibraryToolbar), findsOneWidget); } diff --git a/app/test/opds/layout_test.dart b/app/test/opds/layout_test.dart index 84c22165..6e366f8d 100644 --- a/app/test/opds/layout_test.dart +++ b/app/test/opds/layout_test.dart @@ -386,7 +386,7 @@ void main() { } await tester.tap( - width < 840 ? find.byTooltip('Add catalog') : find.widgetWithText(FilledButton, 'Add catalog'), + width < 840 ? find.byTooltip('Add catalog') : find.byType(LibraryAddButton), ); await _settle(tester); diff --git a/app/test/pages/book_edit_page_layout_test.dart b/app/test/pages/book_edit_page_layout_test.dart index 66670a63..a2dc3418 100644 --- a/app/test/pages/book_edit_page_layout_test.dart +++ b/app/test/pages/book_edit_page_layout_test.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/pages/book_edit_page.dart'; +import 'package:papyrus/providers/book_edit_provider.dart'; +import 'package:papyrus/widgets/book_details/book_info_grid.dart'; +import 'package:provider/provider.dart'; import 'package:papyrus/themes/app_theme.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/book_edit/cover_image_picker.dart'; @@ -43,6 +46,29 @@ void main() { await tester.pumpAndSettle(); } + for (final width in [390.0, 1400.0]) { + testWidgets('genre can be edited and cleared at $width', (tester) async { + await pumpPage(tester, size: Size(width, 1000)); + final field = find.ancestor(of: find.text('Genre'), matching: find.byType(TextFormField)); + await tester.ensureVisible(field); + await tester.enterText(field, 'Science fiction'); + final editor = tester.element(field).read(); + expect(editor.editedBook?.genre, 'Science fiction'); + expect(await editor.save(), isTrue); + final saved = editor.editedBook!; + await tester.enterText(field, ''); + expect(editor.editedBook?.genre, isNull); + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: BookInfoGrid(book: saved)), + ), + ); + expect(find.text('Genre'), findsOneWidget); + expect(find.text('Science fiction'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + testWidgets('desktop uses a left-aligned page header with a persistent Save action', (tester) async { await pumpPage(tester, size: const Size(1400, 1000)); expect(find.text('Edit book'), findsOneWidget); diff --git a/app/test/pages/licenses_page_test.dart b/app/test/pages/licenses_page_test.dart index 0b76ca05..17322c39 100644 --- a/app/test/pages/licenses_page_test.dart +++ b/app/test/pages/licenses_page_test.dart @@ -1,5 +1,9 @@ +import 'dart:async'; +import 'dart:ui' as ui; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/pages/licenses_page.dart'; import 'package:papyrus/themes/app_theme.dart'; @@ -17,6 +21,76 @@ void main() { }); tearDown(LicenseRegistry.reset); + testWidgets('prepared licenses render on the first frame', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark, + home: const LicensesPage( + initialLicenses: { + 'alpha': [ + LicenseEntryWithLineBreaks(['alpha'], 'Alpha license text'), + ], + }, + ), + ), + ); + + expect(find.widgetWithText(ListTile, 'alpha'), findsOneWidget); + expect(find.text('Loading licenses…'), findsNothing); + }); + + testWidgets('fast loads never flash loading feedback', (tester) async { + await tester.pumpWidget(MaterialApp(theme: AppTheme.dark, home: const LicensesPage())); + expect(find.text('Loading licenses…'), findsNothing); + await tester.pump(); + expect(find.widgetWithText(ListTile, 'alpha'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 800)); + expect(find.text('Loading licenses…'), findsNothing); + }); + + for (final fails in [false, true]) { + testWidgets('slow load feedback stays readable before completion, fails=$fails', (tester) async { + final result = Completer(); + LicenseRegistry.reset(); + LicenseRegistry.addLicense(() => Stream.fromFuture(result.future)); + await tester.pumpWidget(MaterialApp(theme: AppTheme.dark, home: const LicensesPage())); + await tester.pump(const Duration(milliseconds: 299)); + expect(find.text('Loading licenses…'), findsNothing); + await tester.pump(const Duration(milliseconds: 1)); + expect(find.text('Loading licenses…'), findsOneWidget); + + if (fails) { + result.completeError(StateError('License load failed')); + } else { + result.complete(const LicenseEntryWithLineBreaks(['alpha'], 'Alpha license text')); + } + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 499)); + expect(find.text('Loading licenses…'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 1)); + await tester.pump(); + expect(find.text('Loading licenses…'), findsNothing); + expect( + fails ? find.text('Could not load licenses. Retry') : find.widgetWithText(ListTile, 'alpha'), + findsOneWidget, + ); + + if (fails) { + LicenseRegistry.reset(); + LicenseRegistry.addLicense( + () => Stream.value(const LicenseEntryWithLineBreaks(['alpha'], 'Alpha license text')), + ); + + await tester.tap(find.text('Could not load licenses. Retry')); + await tester.pump(); + expect(find.text('Loading licenses…'), findsNothing); + await tester.pumpAndSettle(); + expect(find.widgetWithText(ListTile, 'alpha'), findsOneWidget); + } + }); + } + testWidgets('wide licenses show all notices and switch without animation', (tester) async { tester.view.physicalSize = const Size(1200, 800); tester.view.devicePixelRatio = 1; @@ -34,6 +108,51 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('scrolling the selected license never paints over the header', (tester) async { + tester.view.physicalSize = const Size(1200, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final captureKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark, + home: RepaintBoundary( + key: captureKey, + child: LicensesPage( + initialLicenses: { + for (var index = 0; index < 40; index++) + 'package-${index.toString().padLeft(2, '0')}': [ + const LicenseEntryWithLineBreaks(['package'], 'License text'), + ], + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + final headerBottom = tester.getBottomLeft(find.byType(LibraryPageHeader)).dy.floor(); + + Future> headerPixels() async { + final boundary = captureKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await boundary.toImage(); + final bytes = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + final pixels = bytes!.buffer.asUint8List(0, image.width * headerBottom * 4).toList(); + image.dispose(); + return pixels; + } + + final before = await tester.runAsync(headerPixels); + final list = find.byKey(const PageStorageKey('license-packages')); + await tester.drag(list, const Offset(0, -50)); + await tester.pumpAndSettle(); + final after = await tester.runAsync(headerPixels); + expect(listEquals(before, after), isTrue, reason: 'Selection ink must stay below the header while scrolling.'); + expect(tester.takeException(), isNull); + }); + testWidgets('compact license navigation and back are immediate', (tester) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; diff --git a/app/test/pages/profile_page_test.dart b/app/test/pages/profile_page_test.dart index cb0390f1..ba691fcc 100644 --- a/app/test/pages/profile_page_test.dart +++ b/app/test/pages/profile_page_test.dart @@ -4,6 +4,8 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:papyrus/themes/app_theme.dart'; import 'package:papyrus/auth/auth_api_client.dart'; import 'package:papyrus/auth/auth_models.dart'; import 'package:papyrus/auth/auth_repository.dart'; @@ -18,8 +20,6 @@ import 'package:papyrus/providers/acquisition_availability_provider.dart'; import 'package:papyrus/providers/auth_provider.dart'; import 'package:papyrus/providers/preferences_provider.dart'; import 'package:papyrus/providers/sync_settings_provider.dart'; -import 'package:papyrus/widgets/profile/profile_menu_item.dart'; -import 'package:papyrus/widgets/settings/settings_section.dart'; import 'package:papyrus/widgets/settings/settings_row.dart'; import 'package:powersync/powersync.dart' hide Column; import 'package:provider/provider.dart'; @@ -117,6 +117,25 @@ void main() { }); Widget buildApp({required Size screenSize, TextScaler textScaler = TextScaler.noScaling}) { + final router = GoRouter( + initialLocation: '/profile', + routes: [ + GoRoute( + path: '/profile', + builder: (_, _) => const ProfilePage(), + routes: [ + for (final section in ProfileSection.values) + GoRoute( + path: section.path, + builder: (_, _) => ProfilePage(section: section), + ), + ], + ), + ], + ); + + addTearDown(router.dispose); + return MultiProvider( providers: [ ChangeNotifierProvider.value(value: authProvider), @@ -128,95 +147,115 @@ void main() { ChangeNotifierProvider.value(value: prefsProvider), ChangeNotifierProvider.value(value: availabilityProvider), ], - child: MaterialApp( - home: MediaQuery( - data: MediaQueryData(size: screenSize, textScaler: textScaler), - child: const ProfilePage(), + child: MaterialApp.router( + theme: AppTheme.light, + routerConfig: router, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(size: screenSize, textScaler: textScaler), + child: child!, ), ), ); } - testWidgets('mobile layout renders all sections and displays logout dialog', (tester) async { + testWidgets('mobile index opens focused pages and Back restores the index', (tester) async { await tester.binding.setSurfaceSize(const Size(400, 900)); addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget(buildApp(screenSize: const Size(400, 900))); await tester.pumpAndSettle(); expect(find.text('Settings'), findsOneWidget); - expect(find.text('Reader Profile'), findsOneWidget); - expect(find.text('reader@example.com'), findsOneWidget); - await tester.scrollUntilVisible( - find.widgetWithText(SettingsSectionHeader, 'Account'), - 300, - scrollable: find.byType(Scrollable).first, - ); - expect(find.text('Change password'), findsOneWidget); - await tester.scrollUntilVisible( - find.widgetWithText(SettingsSectionHeader, 'Appearance'), - 300, - scrollable: find.byType(Scrollable).first, - ); - expect(find.text('Theme'), findsOneWidget); - expect(find.text('Default font'), findsNothing); - expect(find.text('Default view'), findsNothing); - expect(find.text('Developer options'), findsNothing); - await tester.scrollUntilVisible( - find.widgetWithText(SettingsSectionHeader, 'Notifications'), - 300, - scrollable: find.byType(Scrollable).first, - ); - expect(find.text('Goal reminders'), findsOneWidget); - final reminders = find.widgetWithText(SettingsToggleRow, 'Goal reminders'); - final remindersValue = prefsProvider.goalReminders; - expect(tester.widget(reminders).onChanged, isNull); - await tester.tap(reminders); - expect(prefsProvider.goalReminders, remindersValue); - await tester.scrollUntilVisible( - find.widgetWithText(SettingsSectionHeader, 'Storage'), - 300, - scrollable: find.byType(Scrollable).first, - ); - expect(find.text('Clear local library'), findsOneWidget); - await tester.scrollUntilVisible( - find.widgetWithText(SettingsSectionHeader, 'Privacy & data'), - 300, - scrollable: find.byType(Scrollable).first, - ); - expect(find.text('Analytics'), findsOneWidget); - final analytics = find.widgetWithText(SettingsToggleRow, 'Analytics'); - final analyticsValue = prefsProvider.analyticsOptIn; - expect(tester.widget(analytics).onChanged, isNull); - await tester.tap(analytics); - expect(prefsProvider.analyticsOptIn, analyticsValue); - - for (final label in ['Privacy policy', 'Export all data', 'Import data', 'Clear local data']) { - final row = tester.widget(find.widgetWithText(SettingsRow, label)); - expect(row.enabled, isFalse); - expect(row.onTap, isNull); + expect(find.byType(TextFormField), findsNothing); + expect(find.text('Theme'), findsNothing); + expect(find.text('Version'), findsNothing); + + for (final label in ['Notifications', 'Privacy']) { + final item = tester.widget(find.widgetWithText(ListTile, label)); + expect(item.enabled, isFalse); + expect(item.onTap, isNull); } - await tester.scrollUntilVisible( - find.widgetWithText(SettingsSectionHeader, 'Accessibility'), - 300, - scrollable: find.byType(Scrollable).first, - ); - expect(find.text('Reduce animations'), findsOneWidget); - await tester.scrollUntilVisible( - find.widgetWithText(SettingsSectionHeader, 'About'), - 300, - scrollable: find.byType(Scrollable).first, - ); - expect(find.text('Version'), findsOneWidget); - await tester.scrollUntilVisible( - find.widgetWithText(ProfileMenuItem, 'Log out'), - 300, - scrollable: find.byType(Scrollable).first, - ); - await tester.tap(find.widgetWithText(ProfileMenuItem, 'Log out')); + + for (final (title, control) in [ + ('Account', 'Change password'), + ('Appearance', 'Theme'), + ('Storage', 'Clear local library'), + ('Accessibility', 'Reduce animations'), + ('About', 'Version'), + ]) { + await tester.ensureVisible(find.widgetWithText(ListTile, title)); + await tester.tap(find.widgetWithText(ListTile, title)); + await tester.pumpAndSettle(); + expect(find.text(title), findsOneWidget); + expect(find.text(control), findsOneWidget); + expect(find.widgetWithText(ListTile, 'About'), findsNothing); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(find.text('Settings'), findsOneWidget); + } + + await tester.ensureVisible(find.widgetWithText(ListTile, 'Log out')); + await tester.tap(find.widgetWithText(ListTile, 'Log out')); await tester.pumpAndSettle(); expect(find.text('Are you sure you want to log out?'), findsOneWidget); await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); - expect(find.text('Are you sure you want to log out?'), findsNothing); + expect(find.text('Settings'), findsOneWidget); + }); + + testWidgets('mobile appearance persists a choice across Back and reopening', (tester) async { + await tester.binding.setSurfaceSize(const Size(400, 900)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(buildApp(screenSize: const Size(400, 900))); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(ListTile, 'Appearance')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(SettingsRow, 'Theme')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Dark')); + await tester.pumpAndSettle(); + expect(PreferencesProvider(prefs).themeModePref, 'dark'); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.text('Dark theme, page layout'), findsOneWidget); + await tester.tap(find.widgetWithText(ListTile, 'Appearance')); + await tester.pumpAndSettle(); + expect(find.text('Dark'), findsOneWidget); + }); + + testWidgets('narrow mobile with enlarged text retains index scroll position on Back', (tester) async { + const size = Size(320, 568); + await tester.binding.setSurfaceSize(size); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(buildApp(screenSize: size, textScaler: TextScaler.linear(2))); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.widgetWithText(ListTile, 'About'), 200); + final before = tester.state(find.byType(Scrollable)).position.pixels; + await tester.tap(find.widgetWithText(ListTile, 'About')); + await tester.pumpAndSettle(); + expect(find.text('Version'), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(tester.state(find.byType(Scrollable)).position.pixels, before); + expect(tester.takeException(), isNull); + }); + + testWidgets('every detail page opens directly and returns to Settings at 320px with enlarged text', (tester) async { + const size = Size(320, 568); + await tester.binding.setSurfaceSize(size); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(buildApp(screenSize: size, textScaler: TextScaler.linear(2))); + await tester.pumpAndSettle(); + final router = GoRouter.of(tester.element(find.byType(ProfilePage))); + + for (final section in ProfileSection.values) { + router.go('/profile/${section.path}'); + await tester.pumpAndSettle(); + expect(find.text(section.title), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(router.routeInformationProvider.value.uri.path, '/profile'); + } }); testWidgets('desktop settings scrolls when the pointer is over empty space beside the form', (tester) async { diff --git a/app/test/pages/profile_storage_sync_test.dart b/app/test/pages/profile_storage_sync_test.dart index 8adb6484..4ce2959b 100644 --- a/app/test/pages/profile_storage_sync_test.dart +++ b/app/test/pages/profile_storage_sync_test.dart @@ -1,7 +1,5 @@ -import 'dart:async'; -import 'dart:ui' show SemanticsAction, Tristate; - import 'package:flutter/material.dart'; +import 'package:papyrus/themes/app_theme.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/acquisition/acquisition_models.dart'; import 'package:papyrus/auth/auth_api_client.dart'; @@ -224,9 +222,10 @@ void main() { ChangeNotifierProvider(create: (_) => PreferencesProvider(prefs)), ], child: MaterialApp( + theme: AppTheme.light, home: MediaQuery( data: MediaQueryData(size: screenSize), - child: pageBuilder?.call(const ProfilePage()) ?? const ProfilePage(), + child: pageBuilder?.call(const ProfilePage()) ?? const ProfilePage(section: ProfileSection.storageSync), ), ), ); @@ -279,12 +278,11 @@ void main() { final service = _FakePowerSyncService(currentMode: LibraryDatabaseMode.guest, currentSyncState: const SyncState()); await tester.pumpWidget(await buildPage(authProvider: auth, powerSyncService: service)); await tester.pumpAndSettle(); - await tester.scrollUntilVisible(find.text('Storage'), 400, scrollable: find.byType(Scrollable).first); await tester.pumpAndSettle(); expect(find.text('Stored on this device'), findsOneWidget); expect(find.text('Export or import a backup'), findsOneWidget); expect(find.text('Clear local library'), findsOneWidget); - expect(find.textContaining('Nothing is sent to Papyrus servers'), findsOneWidget); + expect(find.textContaining('Nothing is sent to Papyrus servers'), findsNothing); expect(find.text('Guest local'), findsNothing); expect(find.text('papyrus-guest.db'), findsNothing); expect(find.text('Metadata sync off'), findsNothing); @@ -306,14 +304,12 @@ void main() { expect(find.text('Enable acquisition'), findsNothing); }); - testWidgets('authenticated mobile profile separates supported acquisition settings from storage', (tester) async { + testWidgets('authenticated mobile storage omits acquisition settings', (tester) async { final auth = await buildAuthProvider(signedIn: true); - final service = _FakePowerSyncService( currentMode: LibraryDatabaseMode.authenticated, currentSyncState: const SyncState(connected: true), ); - final availability = AcquisitionAvailabilityProvider(loadCapabilities: (_) async => _capabilities(enabled: true)); await availability.refresh(Uri.parse('https://api.test')); @@ -322,92 +318,9 @@ void main() { ); await tester.pumpAndSettle(); - await tester.scrollUntilVisible( - find.byKey(const Key('profile-acquisition-section')), - 400, - scrollable: find.byType(Scrollable).first, - ); - await tester.pumpAndSettle(); - final acquisitionSection = find.byKey(const Key('profile-acquisition-section')); - expect(find.descendant(of: acquisitionSection, matching: find.text('Data sync')), findsNothing); - expect(find.descendant(of: acquisitionSection, matching: find.text('Acquisition')), findsOneWidget); - - expect( - find.descendant( - of: acquisitionSection, - matching: find.text('Search external sources and send releases to your connected clients.'), - ), - findsOneWidget, - ); - - expect(find.descendant(of: acquisitionSection, matching: find.text('Available on this server')), findsOneWidget); - expect(find.descendant(of: acquisitionSection, matching: find.text('Manage integrations')), findsNothing); - final semantics = tester.getSemantics(find.bySemanticsLabel('Enable acquisition')); - expect(semantics.label, 'Enable acquisition'); - expect(semantics.flagsCollection.isEnabled, Tristate.isTrue); - expect(semantics.flagsCollection.isToggled, Tristate.isFalse); - expect(semantics.getSemanticsData().hasAction(SemanticsAction.tap), isTrue); - await tester.tap(find.bySemanticsLabel('Enable acquisition')); - await tester.pumpAndSettle(); - expect(tester.getSemantics(find.bySemanticsLabel('Enable acquisition')).flagsCollection.isToggled, Tristate.isTrue); - expect(find.descendant(of: acquisitionSection, matching: find.text('Manage integrations')), findsOneWidget); - }); - - testWidgets('authenticated mobile profile reports pending acquisition availability', (tester) async { - final completer = Completer(); - final auth = await buildAuthProvider(signedIn: true); - - final service = _FakePowerSyncService( - currentMode: LibraryDatabaseMode.authenticated, - currentSyncState: const SyncState(connected: true), - ); - - final availability = AcquisitionAvailabilityProvider(loadCapabilities: (_) => completer.future); - final refresh = availability.refresh(Uri.parse('https://api.test')); - - await tester.pumpWidget( - await buildPage(authProvider: auth, powerSyncService: service, acquisitionAvailabilityProvider: availability), - ); - - await tester.pump(); - await tester.scrollUntilVisible( - find.byKey(const Key('profile-acquisition-section')), - 400, - scrollable: find.byType(Scrollable).first, - ); - await tester.pump(); - expect(find.text('Checking server support…'), findsOneWidget); - completer.complete(_capabilities(enabled: true)); - await refresh; - await tester.pumpAndSettle(); - expect(find.text('Available on this server'), findsOneWidget); - }); - - testWidgets('authenticated mobile profile hides acquisition management when server is unavailable', (tester) async { - final auth = await buildAuthProvider(signedIn: true); - - final service = _FakePowerSyncService( - currentMode: LibraryDatabaseMode.authenticated, - currentSyncState: const SyncState(connected: true), - ); - - final availability = AcquisitionAvailabilityProvider(loadCapabilities: (_) async => _capabilities(enabled: false)); - await availability.refresh(Uri.parse('https://api.test')); - - await tester.pumpWidget( - await buildPage(authProvider: auth, powerSyncService: service, acquisitionAvailabilityProvider: availability), - ); - - await tester.pumpAndSettle(); - await tester.scrollUntilVisible(find.text('Storage'), 400, scrollable: find.byType(Scrollable).first); - await tester.pumpAndSettle(); - final acquisitionSection = find.byKey(const Key('profile-acquisition-section')); - expect(acquisitionSection, findsOneWidget); - expect(find.text('Enable acquisition'), findsOneWidget); - expect(find.text('Unavailable on this server'), findsOneWidget); - expect(find.text('Manage integrations'), findsNothing); - await tester.tap(find.bySemanticsLabel('Enable acquisition')); - await tester.pumpAndSettle(); + expect(find.text('File storage'), findsOneWidget); + expect(find.text('Acquisition'), findsNothing); + expect(find.text('Enable acquisition'), findsNothing); expect(find.text('Manage integrations'), findsNothing); }); @@ -424,7 +337,7 @@ void main() { await tester.pump(); expect(find.text('Library storage'), findsOneWidget); expect(find.text('Your library is stored on this device.'), findsOneWidget); - expect(find.textContaining('Nothing is sent to Papyrus servers'), findsOneWidget); + expect(find.textContaining('Nothing is sent to Papyrus servers'), findsNothing); expect(find.text('Export backup'), findsOneWidget); expect(find.text('Import backup'), findsOneWidget); expect(find.text('Clear local library'), findsOneWidget); @@ -443,6 +356,8 @@ void main() { }); testWidgets('authenticated storage sync UI shows data sync and hides implementation details', (tester) async { + await tester.binding.setSurfaceSize(const Size(1200, 900)); + addTearDown(() => tester.binding.setSurfaceSize(null)); final auth = await buildAuthProvider(signedIn: true); final dataStore = dataStoreWithBooks([ @@ -474,15 +389,23 @@ void main() { expect(find.text('Data sync'), findsOneWidget); expect(find.text('Official server'), findsWidgets); expect(find.text('350 MB used, 674 MB available of 1 GB'), findsOneWidget); + expect(tester.widget(find.byType(LinearProgressIndicator)).value, 350 / 1024); + final reconnectButton = find.widgetWithText(OutlinedButton, 'Reconnect'); + final manageButton = find.widgetWithText(OutlinedButton, 'Manage servers'); + expect( + tester.getSize(reconnectButton).width, + lessThan(tester.getSize(find.byKey(const Key('profile-data-sync-card'))).width / 2), + ); + expect(tester.getTopLeft(reconnectButton).dy, tester.getTopLeft(manageButton).dy); expect(find.text('Connected'), findsWidgets); expect(find.text('Reconnect'), findsOneWidget); expect(find.text('Manage servers'), findsOneWidget); expect(find.byKey(const Key('profile-data-sync-card')), findsOneWidget); - expect(find.byKey(const Key('profile-acquisition-card')), findsOneWidget); - expect(find.text('Acquisition'), findsOneWidget); - expect(find.text('Enable acquisition'), findsOneWidget); - expect(find.text('Available on this server'), findsOneWidget); - expect(find.text('Search external sources and send releases to your connected clients.'), findsOneWidget); + expect(find.byKey(const Key('profile-acquisition-card')), findsNothing); + expect(find.text('Acquisition'), findsNothing); + expect(find.text('Enable acquisition'), findsNothing); + expect(find.text('Available on this server'), findsNothing); + expect(find.text('Search external sources and send releases to your connected clients.'), findsNothing); expect(find.text('Manage integrations'), findsNothing); expect(find.text('Clear local copy'), findsOneWidget); expect(find.text('Clear account local cache'), findsNothing); @@ -499,38 +422,6 @@ void main() { await tester.tap(find.text('Reconnect')); await tester.pump(); expect(service.reconnectCalls, 1); - await tester.tap(find.bySemanticsLabel('Enable acquisition')); - await tester.pumpAndSettle(); - expect(find.text('Manage integrations'), findsOneWidget); - }); - - testWidgets('authenticated desktop profile reports unsupported acquisition separately', (tester) async { - final auth = await buildAuthProvider(signedIn: true); - - final service = _FakePowerSyncService( - currentMode: LibraryDatabaseMode.authenticated, - currentSyncState: const SyncState(connected: true), - ); - - final availability = AcquisitionAvailabilityProvider(loadCapabilities: (_) async => _capabilities(enabled: false)); - await availability.refresh(Uri.parse('https://api.test')); - - await tester.pumpWidget( - await buildPage( - authProvider: auth, - powerSyncService: service, - screenSize: const Size(1200, 900), - acquisitionAvailabilityProvider: availability, - ), - ); - - await tester.pumpAndSettle(); - await tester.tap(find.text('Storage').first); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('profile-data-sync-card')), findsOneWidget); - expect(find.byKey(const Key('profile-acquisition-card')), findsOneWidget); - expect(find.text('Unavailable on this server'), findsOneWidget); - expect(find.text('Manage integrations'), findsNothing); }); testWidgets('manage servers lists official and custom servers for switching', (tester) async { @@ -562,7 +453,6 @@ void main() { ); await tester.pumpAndSettle(); - await tester.scrollUntilVisible(find.text('Storage'), 400, scrollable: find.byType(Scrollable).first); await tester.pumpAndSettle(); await tester.tap(find.text('Manage servers')); await tester.pumpAndSettle(); 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/pages/statistics_page_test.dart b/app/test/pages/statistics_page_test.dart new file mode 100644 index 00000000..3cb3cf8e --- /dev/null +++ b/app/test/pages/statistics_page_test.dart @@ -0,0 +1,288 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/models/reading_activity.dart'; +import 'package:papyrus/pages/statistics_page.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/widgets/statistics/reading_charts.dart'; +import 'package:papyrus/widgets/statistics/stat_card.dart'; +import 'package:provider/provider.dart'; + +Future pumpStatistics( + WidgetTester tester, { + Size size = const Size(390, 844), + double scale = 1, + ThemeData? theme, + bool empty = false, + PreferencesProvider? preferences, + DateTime? at, +}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.reset); + final now = at ?? DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final store = DataStore()..loadData(); + addTearDown(store.dispose); + + if (!empty) { + await store.commitTracking( + activities: [ + ReadingActivity( + id: 'today', + bookId: 'book', + bookTitle: 'A book', + startTime: today, + endTime: today.add(const Duration(minutes: 30)), + createdAt: today, + pages: 12, + ), + ReadingActivity( + id: 'previous-year', + bookId: 'book', + bookTitle: 'A book', + startTime: DateTime(now.year - 1), + endTime: DateTime(now.year - 1).add(const Duration(minutes: 60)), + createdAt: DateTime(now.year - 1), + pages: 24, + ), + ], + ); + } + + if (preferences == null) { + SharedPreferences.setMockInitialValues({}); + preferences = PreferencesProvider(await SharedPreferences.getInstance()); + addTearDown(preferences.dispose); + } + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: store), + ChangeNotifierProvider.value(value: preferences), + ], + child: MaterialApp( + theme: theme ?? AppTheme.light, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + home: StatisticsPage(now: () => now), + ), + ), + ); + + await tester.pumpAndSettle(); +} + +void main() { + for (final width in [390.0, 1440.0]) { + testWidgets('page alignment updates statistics with standard margins at $width', (tester) async { + SharedPreferences.setMockInitialValues({}); + final preferences = PreferencesProvider(await SharedPreferences.getInstance()); + addTearDown(preferences.dispose); + await pumpStatistics(tester, size: Size(width, 1000), preferences: preferences); + final period = find.byKey(const ValueKey('statistics-period-week')); + final margin = width < 840 ? 16.0 : 24.0; + final custom = width < 600 + ? find.widgetWithIcon(IconButton, Icons.date_range_outlined) + : find.widgetWithIcon(OutlinedButton, Icons.date_range_outlined); + expect(tester.getRect(period).left, margin); + expect(tester.getRect(period).top, margin); + expect(tester.getRect(custom).right, width - margin); + preferences.pageAlignment = 'center'; + await tester.pumpAndSettle(); + final centeredMargin = width > 1168 ? (width - 1120) / 2 : margin; + expect(tester.getRect(period).left, centeredMargin); + expect(tester.getRect(period).top, margin); + expect(tester.getRect(custom).right, width - centeredMargin); + preferences.pageAlignment = 'left'; + await tester.pumpAndSettle(); + expect(tester.getRect(period).left, margin); + expect(tester.getRect(custom).right, width - margin); + expect(tester.takeException(), isNull); + }); + } + + for (final width in [390.0, 1200.0]) { + testWidgets('Monday Week shows all seven days at $width', (tester) async { + await pumpStatistics(tester, size: Size(width, 1000), at: DateTime(2026, 11, 2, 12)); + expect(find.text('Nov 2, 2026 – Nov 8, 2026'), findsOneWidget); + + for (final metric in ['Time', 'Pages']) { + await tester.tap(find.text(metric)); + await tester.pumpAndSettle(); + final chart = tester.widget(find.byType(ReadingActivityBarChart)); + expect(chart.activities.length, 7); + expect(chart.activities.first.date, DateTime(2026, 11, 2)); + expect(chart.activities.last.date, DateTime(2026, 11, 8)); + expect(chart.activities.skip(1).every((day) => day.readingMinutes == 0 && day.pagesRead == 0), isTrue); + + for (var day = 0; day < 7; day++) { + expect(find.byKey(ValueKey('activity-axis-tick-$day')), findsOneWidget); + } + + if (width > 600) { + for (final day in ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']) { + expect(find.text(day), findsOneWidget); + } + } + } + + expect(tester.takeException(), isNull); + }); + } + + testWidgets('period and chart controls update totals while broader scopes stay explicit', (tester) async { + await pumpStatistics(tester, size: const Size(1200, 900)); + expect(find.text('30m'), findsWidgets); + expect(find.byType(ReadingActivityBarChart), findsOneWidget); + await tester.tap(find.text('Pages')); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byType(ReadingActivityBarChart)).metric, + ReadingActivityMetric.pages, + ); + await tester.tap(find.text('All time')); + await tester.pumpAndSettle(); + expect(find.text('1.5h'), findsOneWidget); + expect(find.textContaining('latest 60 days'), findsNothing); + expect(find.text('Reading by genre'), findsOneWidget); + expect(find.textContaining('Lifetime streaks'), findsOneWidget); + expect(find.byType(ExpansionTile), findsNothing); + expect(find.byType(Divider), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('custom dates do not falsely select Week and cancelling preserves the range', (tester) async { + await pumpStatistics(tester, size: const Size(1200, 900)); + await tester.tap(find.text('Custom range')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Apply').last); + await tester.pumpAndSettle(); + expect( + tester + .widget( + find + .ancestor(of: find.byKey(const ValueKey('statistics-period-week')), matching: find.byType(Semantics)) + .first, + ) + .properties + .selected, + isFalse, + ); + expect(find.text('This week'), findsNothing); + final dateButton = find.widgetWithIcon(OutlinedButton, Icons.date_range_outlined); + await tester.tap(dateButton); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Close')); + await tester.pumpAndSettle(); + expect( + tester + .widget( + find + .ancestor(of: find.byKey(const ValueKey('statistics-period-week')), matching: find.byType(Semantics)) + .first, + ) + .properties + .selected, + isFalse, + ); + await tester.tap(find.text('Week')); + await tester.pumpAndSettle(); + expect(find.text('This week'), findsWidgets); + expect(tester.takeException(), isNull); + }); + + testWidgets('adjacent panels match height and activity controls share the header', (tester) async { + await pumpStatistics(tester, size: const Size(1200, 1000)); + final cards = find.byType(StatSectionCard); + expect(tester.getSize(cards.at(0)).height, tester.getSize(cards.at(1)).height); + expect(tester.getSize(cards.at(2)).height, tester.getSize(cards.at(3)).height); + final header = tester.getRect(find.text('Reading activity')); + final controls = tester.getRect(find.byType(ChoiceChip).first); + expect(controls.left, greaterThan(header.right)); + expect(controls.center.dy, greaterThan(header.center.dy)); + expect(controls.center.dy, lessThan(header.bottom + 24)); + expect(tester.takeException(), isNull); + }); + + testWidgets('phone custom range stays beside periods and empty states omit explanatory copy', (tester) async { + await pumpStatistics(tester, size: const Size(320, 740), empty: true); + final week = tester.getRect(find.byKey(const ValueKey('statistics-period-week'))); + final custom = tester.getRect(find.widgetWithIcon(IconButton, Icons.date_range_outlined)); + expect(custom.center.dy, closeTo(week.center.dy, 1)); + expect(custom.right, closeTo(304, 1)); + expect(find.text('Custom range'), findsNothing); + expect(find.textContaining('Read a book or log reading'), findsNothing); + expect(find.textContaining('Daily average uses'), findsNothing); + await tester.tap(find.byTooltip('Custom range')); + await tester.pumpAndSettle(); + expect(find.text('Select date range'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + for (final size in [const Size(390, 844), const Size(1200, 900)]) { + testWidgets('period selection keeps button geometry fixed at ${size.width}', (tester) async { + await pumpStatistics(tester, size: size); + final keys = ['week', 'month', 'year', 'allTime']; + Rect buttonRect(String key) => tester.getRect(find.byKey(ValueKey('statistics-period-$key'))); + final original = {for (final key in keys) key: buttonRect(key)}; + final custom = size.width < 600 + ? find.widgetWithIcon(IconButton, Icons.date_range_outlined) + : find.widgetWithIcon(OutlinedButton, Icons.date_range_outlined); + final customRect = tester.getRect(custom); + expect(customRect.right, closeTo(size.width - (size.width < 600 ? 16 : 24), 1)); + + for (final period in ['month', 'week']) { + await tester.tap(find.byKey(ValueKey('statistics-period-$period'))); + await tester.pumpAndSettle(); + + for (final key in keys) { + expect(buttonRect(key), original[key]); + } + + expect(tester.getRect(custom), customRect); + } + + await tester.tap(custom); + await tester.pumpAndSettle(); + await tester.tap(find.text('Apply').last); + await tester.pumpAndSettle(); + + for (final key in keys) { + expect(buttonRect(key), original[key]); + } + + expect(tester.getRect(custom), customRect); + expect(find.textContaining('·'), findsNothing); + expect(tester.takeException(), isNull); + }); + } + + for (final scenario in [ + (name: 'small phone', size: const Size(320, 740), scale: 1.0, theme: AppTheme.light), + (name: 'large text', size: const Size(390, 844), scale: 2.0, theme: AppTheme.dark), + (name: 'tablet e-ink', size: const Size(768, 1024), scale: 1.3, theme: AppTheme.eink), + (name: 'desktop', size: const Size(1440, 900), scale: 1.0, theme: AppTheme.dark), + ]) { + testWidgets('${scenario.name} keeps populated and empty content usable', (tester) async { + await pumpStatistics(tester, size: scenario.size, scale: scenario.scale, theme: scenario.theme); + expect(tester.takeException(), isNull); + await tester.scrollUntilVisible( + find.text('Reading by genre'), + 300, + scrollable: find.byType(Scrollable).first, + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await pumpStatistics(tester, size: scenario.size, scale: scenario.scale, theme: scenario.theme, empty: true); + expect(find.text('No reading time in this range'), findsOneWidget); + expect(find.byType(ReadingActivityBarChart), findsNothing); + expect(tester.takeException(), isNull); + }); + } +} diff --git a/app/test/pages/support_navigation_test.dart b/app/test/pages/support_navigation_test.dart index de9b1294..fd307b38 100644 --- a/app/test/pages/support_navigation_test.dart +++ b/app/test/pages/support_navigation_test.dart @@ -12,6 +12,7 @@ import 'package:papyrus/opds/opds_catalog_store.dart'; import 'package:papyrus/opds/opds_catalogs.dart'; import 'package:papyrus/pages/support_page.dart'; import 'package:papyrus/providers/sidebar_provider.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; import 'package:papyrus/services/support_api_client.dart'; import 'package:papyrus/themes/app_theme.dart'; import 'package:papyrus/widgets/shell/adaptive_app_shell.dart'; @@ -57,6 +58,7 @@ void main() { final completion = Completer(); final clients = <_TrackingClient>[]; var submissions = 0; + final preferences = await SharedPreferences.getInstance(); final catalogs = OpdsCatalogs( OpdsCatalogStore(await SharedPreferences.getInstance(), secrets: MemorySecrets()), @@ -110,6 +112,7 @@ void main() { providers: [ ChangeNotifierProvider(create: (_) => DataStore()), ChangeNotifierProvider(create: (_) => SidebarProvider()), + ChangeNotifierProvider(create: (_) => PreferencesProvider(preferences)), ChangeNotifierProvider.value(value: catalogs), ], child: MaterialApp.router(theme: AppTheme.eink, routerConfig: router), diff --git a/app/test/pages/support_page_test.dart b/app/test/pages/support_page_test.dart index 4d1d8146..f4105754 100644 --- a/app/test/pages/support_page_test.dart +++ b/app/test/pages/support_page_test.dart @@ -9,13 +9,21 @@ import 'package:http/testing.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:papyrus/auth/papyrus_api_config.dart'; import 'package:papyrus/pages/support_page.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:papyrus/services/support_api_client.dart'; import 'package:papyrus/themes/app_theme.dart'; import '../helpers/support_file_picker.dart'; void main() { - setUp(() { + late PreferencesProvider preferences; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + preferences = PreferencesProvider(await SharedPreferences.getInstance()); + addTearDown(preferences.dispose); PackageInfo.setMockInitialValues( appName: 'Papyrus', packageName: 'papyrus', @@ -25,6 +33,45 @@ void main() { ); }); + for (final width in [390.0, 1280.0]) { + testWidgets('page alignment updates without losing the draft at width $width', (tester) async { + tester.view.physicalSize = Size(width, 1100); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final client = SupportApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.example')), + httpClient: MockClient((request) async => http.Response('', 503)), + ); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark, + home: ChangeNotifierProvider.value( + value: preferences, + child: SupportPage(client: client), + ), + ), + ); + + await tester.pumpAndSettle(); + final leftBounds = tester.getRect(find.byType(Form)); + expect(leftBounds.left, 24); + expect(leftBounds.width, width > 688 ? 640 : width - 48); + await tester.enterText(find.byType(TextFormField).first, 'Reader issue'); + preferences.pageAlignment = 'center'; + await tester.pumpAndSettle(); + final centeredBounds = tester.getRect(find.byType(Form)); + expect(centeredBounds.center.dx, width / 2); + expect(centeredBounds.width, leftBounds.width); + expect(find.text('Reader issue'), findsOneWidget); + preferences.pageAlignment = 'left'; + await tester.pumpAndSettle(); + expect(tester.getRect(find.byType(Form)), leftBounds); + expect(find.text('Reader issue'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + testWidgets('validation, failure recovery, and single successful submission', (tester) async { tester.view.physicalSize = const Size(1000, 1100); tester.view.devicePixelRatio = 1; @@ -48,7 +95,10 @@ void main() { await tester.pumpWidget( MaterialApp( theme: AppTheme.eink, - home: SupportPage(client: client), + home: ChangeNotifierProvider.value( + value: preferences, + child: SupportPage(client: client), + ), ), ); await tester.pumpAndSettle(); @@ -98,7 +148,10 @@ void main() { data: MediaQuery.of(context).copyWith(textScaler: const TextScaler.linear(1.5)), child: child!, ), - home: SupportPage(client: client), + home: ChangeNotifierProvider.value( + value: preferences, + child: SupportPage(client: client), + ), ), ); await tester.pumpAndSettle(); @@ -139,7 +192,10 @@ void main() { await tester.pumpWidget( MaterialApp( theme: AppTheme.dark, - home: SupportPage(client: client), + home: ChangeNotifierProvider.value( + value: preferences, + child: SupportPage(client: client), + ), ), ); await tester.pumpAndSettle(); 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/powersync/storage_sync_controller_test.dart b/app/test/powersync/storage_sync_controller_test.dart index a0f71a14..98cd7150 100644 --- a/app/test/powersync/storage_sync_controller_test.dart +++ b/app/test/powersync/storage_sync_controller_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/media/media_models.dart'; import 'package:papyrus/auth/auth_api_client.dart'; import 'package:papyrus/auth/auth_models.dart'; import 'package:papyrus/auth/auth_repository.dart'; @@ -91,4 +92,35 @@ void main() { expect(controller.hasFailedMediaUploads, isTrue); expect(controller.failedMediaUploadLabel, '2 failed'); }); + + test('storage fraction uses server usage and handles capacity limits', () async { + final prefs = await SharedPreferences.getInstance(); + final auth = AuthProvider(prefs, repository: _FakeAuthRepository(), bootstrapOnCreate: false); + await auth.bootstrap(); + final settings = SyncSettingsProvider( + prefs, + officialConfig: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.test')), + ); + + StorageSyncController controller({MediaStorageUsage? usage, int usedBytes = 0}) { + return StorageSyncController( + authProvider: auth, + powerSyncService: _FakePowerSyncService(), + syncSettings: settings, + syncState: const SyncState(), + fileStorageUsedBytes: usedBytes, + mediaStorageUsage: usage, + ); + } + + expect(controller(usedBytes: 536870912).fileStorageFraction, 0.5); + expect(controller().fileStorageFraction, 0); + + for (final (used, quota, expected) in [(25, 100, 0.25), (120, 100, 1.0), (0, 0, null)]) { + final state = controller( + usage: MediaStorageUsage(usedBytes: used, quotaBytes: quota, availableBytes: 0), + ); + expect(state.fileStorageFraction, expected); + } + }); } diff --git a/app/test/powersync/tracking_persistence_test.dart b/app/test/powersync/tracking_persistence_test.dart index a22da10e..a9e5b8ac 100644 --- a/app/test/powersync/tracking_persistence_test.dart +++ b/app/test/powersync/tracking_persistence_test.dart @@ -33,6 +33,11 @@ void main() { final activity = ReadingActivity( id: 'reading', + source: 'reader', + coverage: const [ + PageCoverage(key: 'book:epub:0:100:1000', start: 0, end: 1, pagesPerUnit: 1), + PageCoverage(key: 'book:epub:0:300:500:1000', start: 0, end: 1, pagesPerUnit: 1), + ], startPosition: 0.25, endPosition: 0.35, deviceType: 'android', diff --git a/app/test/providers/appearance_preferences_test.dart b/app/test/providers/appearance_preferences_test.dart new file mode 100644 index 00000000..c9fa7f99 --- /dev/null +++ b/app/test/providers/appearance_preferences_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('page alignment defaults left and both choices survive storage reload', () async { + SharedPreferences.setMockInitialValues({}); + final storage = await SharedPreferences.getInstance(); + final preferences = PreferencesProvider(storage); + addTearDown(preferences.dispose); + expect(preferences.pageAlignment, 'left'); + var notifications = 0; + preferences.addListener(() => notifications++); + + for (final alignment in ['center', 'left']) { + preferences.pageAlignment = alignment; + await storage.reload(); + final restored = PreferencesProvider(storage); + expect(restored.pageAlignment, alignment); + restored.dispose(); + } + + expect(notifications, 2); + }); + + for (final value in ['invalid', true, 1]) { + test('invalid saved page alignment $value uses left', () async { + SharedPreferences.setMockInitialValues({'page_alignment': value}); + final preferences = PreferencesProvider(await SharedPreferences.getInstance()); + addTearDown(preferences.dispose); + expect(preferences.pageAlignment, 'left'); + }); + } +} diff --git a/app/test/providers/book_edit_persistence_test.dart b/app/test/providers/book_edit_persistence_test.dart index 86b26d71..e13b56fe 100644 --- a/app/test/providers/book_edit_persistence_test.dart +++ b/app/test/providers/book_edit_persistence_test.dart @@ -39,6 +39,29 @@ void main() { await directory.delete(recursive: true); }); + test('genre edits persist, can be cleared, and preserve other custom metadata', () async { + final original = await service.getById('book'); + await service.scopedBooks.update( + original!.copyWith(customMetadata: {'source': 'imported', 'genre': 'Fiction'}), + previous: original, + ); + + await editor.loadBook('book'); + editor.updateGenre(' Science fiction '); + expect(await editor.save(), isTrue); + await service.activateAuthenticated('other-account'); + await service.activateGuest(); + final saved = await service.getById('book'); + expect(saved?.genre, 'Science fiction'); + expect(saved?.customMetadata?['source'], 'imported'); + await editor.loadBook('book'); + editor.updateGenre(' '); + expect(await editor.save(), isTrue); + final cleared = await service.getById('book'); + expect(cleared?.genre, isNull); + expect(cleared?.customMetadata, {'source': 'imported'}); + }); + test('saving a stale editor preserves an unrelated remote field', () async { final original = editor.originalBook!; editor.updateTitle('Edited title'); 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/providers/statistics_provider_test.dart b/app/test/providers/statistics_provider_test.dart index e29d8301..e4d09d5b 100644 --- a/app/test/providers/statistics_provider_test.dart +++ b/app/test/providers/statistics_provider_test.dart @@ -8,6 +8,56 @@ import 'package:papyrus/providers/enums/library_reading_status.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + for (final now in [DateTime(2026, 11, 2, 12), DateTime(2026, 11, 4, 12), DateTime(2026, 11, 8, 12)]) { + test('calendar charts retain empty future buckets on weekday ${now.weekday}', () async { + final store = DataStore()..loadData(); + addTearDown(store.dispose); + final monday = DateTime(2026, 11, 2); + await store.commitTracking( + activities: [ + ReadingActivity( + id: 'monday', + bookId: 'book', + bookTitle: 'Book', + startTime: monday, + endTime: monday.add(const Duration(minutes: 7)), + createdAt: monday.add(const Duration(minutes: 7)), + pages: 2, + ), + ], + ); + final provider = StatisticsProvider(now: () => now)..attach(store); + addTearDown(provider.dispose); + expect(provider.readingTimeData.length, 7); + expect(provider.readingTimeData.last.date, DateTime(2026, 11, 8)); + expect(provider.readingTimeData.first.seconds, 420); + expect(provider.readingTimeData.skip(1).every((day) => day.seconds == 0 && day.pages == 0), isTrue); + expect(provider.totalReadingSeconds, 420); + expect(provider.pagesRead, 2); + expect(provider.periodDays, now.weekday); + expect(provider.averageDailyMinutes, (7 / now.weekday).round()); + provider.setPeriod(StatsPeriod.month); + expect(provider.readingTimeData.length, 30); + expect(provider.periodDays, now.day); + provider.setPeriod(StatsPeriod.year); + expect(provider.bucketSize, StatisticsBucketSize.month); + expect(provider.readingTimeData.length, 12); + expect(provider.readingTimeData.last.date, DateTime(2026, 12)); + expect(provider.readingTimeData.last.seconds, 0); + }); + } + + test('January uses twelve monthly ticks from the first day', () { + final store = DataStore()..loadData(); + addTearDown(store.dispose); + final provider = StatisticsProvider(now: () => DateTime(2026, 1, 1, 12))..attach(store); + addTearDown(provider.dispose); + provider.setPeriod(StatsPeriod.year); + expect(provider.bucketSize, StatisticsBucketSize.month); + expect(provider.readingTimeData.length, 12); + expect(provider.periodDays, 1); + }); + test('historical completion dates survive the ledger upgrade without resurrecting undone entries', () async { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); @@ -60,10 +110,174 @@ void main() { provider.setPeriod(StatsPeriod.allTime); expect(provider.totalBooks, 3); expect(provider.monthlyStats.last.booksRead, 2); - expect(provider.monthlyStats[10].booksRead, 1); + expect(provider.monthlyStats.first.booksRead, 1); expect(provider.readingTimeData.last.booksRead, unorderedEquals(['legacy-today', 'tracked'])); provider.setCustomDateRange(previousMonth, previousMonth); expect(provider.totalBooks, 1); expect(store.readingActivities.where((activity) => activity.bookId.startsWith('legacy')), isEmpty); }); + test('full-period buckets preserve old activity and fractional minutes', () async { + final store = DataStore()..loadData(); + addTearDown(store.dispose); + final now = DateTime(2026, 11, 2, 12); + + ReadingActivity reading(String id, DateTime start, int seconds, int pages) => ReadingActivity( + id: id, + bookId: id, + bookTitle: id, + startTime: start, + endTime: start.add(Duration(seconds: seconds)), + createdAt: start, + pages: pages, + ); + + await store.commitTracking( + activities: [ + reading('january', DateTime(2026, 1, 10), 30, 4), + reading('october', DateTime(2026, 10, 15), 30, 6), + reading('november', DateTime(2026, 11, 1), 600, 12), + ], + ); + + final provider = StatisticsProvider(now: () => now)..attach(store); + addTearDown(provider.dispose); + provider.setPeriod(StatsPeriod.year); + expect(provider.bucketSize, StatisticsBucketSize.month); + expect(provider.readingTimeData.length, 12); + expect(provider.readingTimeData.first.seconds, 30); + expect(provider.readingTimeData.fold(0, (sum, bucket) => sum + bucket.seconds), 660); + expect(provider.readingTimeData.fold(0, (sum, bucket) => sum + bucket.pages), 22); + expect(provider.totalReadingSeconds, 660); + expect(provider.pagesRead, 22); + expect(provider.periodDays, 306); + expect(provider.averageReadingLabel, '2s'); + expect(provider.monthlyStats.length, 11); + + provider.setCustomDateRange(DateTime(2026, 10, 15), DateTime(2026, 11, 1)); + expect(provider.bucketSize, StatisticsBucketSize.day); + expect(provider.periodDays, 18); + expect(provider.totalReadingSeconds, 630); + expect(provider.readingTimeData.first.date, DateTime(2026, 10, 15)); + expect(provider.readingTimeData.last.endDate, DateTime(2026, 11, 2)); + expect(provider.averageReadingLabel, '35s'); + }); + + test('genres count distinct books read in range and respect corrections', () async { + final now = DateTime(2026, 11, 2, 12); + final store = DataStore() + ..loadData( + books: [ + for (final id in ['read', 'old', 'undone', 'unread']) + Book(id: id, title: id, author: '', addedAt: now, customMetadata: {'genre': id}), + ], + ); + addTearDown(store.dispose); + + ReadingActivity reading(String id, String bookId, DateTime start) => ReadingActivity( + id: id, + bookId: bookId, + bookTitle: bookId, + startTime: start, + endTime: start.add(const Duration(minutes: 10)), + createdAt: start, + ); + + final undone = reading('undo', 'undone', DateTime(2026, 11, 1)); + + await store.commitTracking( + activities: [ + reading('one', 'read', DateTime(2026, 11, 1)), + reading('two', 'read', DateTime(2026, 11, 2)), + reading('old', 'old', DateTime(2026, 9, 1)), + undone, + store.reversalFor(undone), + ], + ); + + final provider = StatisticsProvider(now: () => now)..attach(store); + addTearDown(provider.dispose); + provider.setPeriod(StatsPeriod.month); + expect(provider.booksRead, 1); + expect(provider.daysRead, 2); + expect(provider.genreDistribution.single.genre, 'read'); + expect(provider.genreDistribution.single.bookCount, 1); + expect(provider.genreDistribution.single.percentage, 1); + final book = store.getBook('read')!; + await store.updateBookAndWait(book.copyWith(customMetadata: {'genre': 'Science fiction'}), previous: book); + expect(provider.genreDistribution.single.genre, 'Science fiction'); + await store.updateBookAndWait(book, previous: store.getBook('read')); + + provider.setPeriod(StatsPeriod.allTime); + expect(provider.booksRead, 2); + expect(provider.genreDistribution.map((genre) => genre.genre), unorderedEquals(['read', 'old'])); + + final emptyStore = DataStore()..loadData(); + addTearDown(emptyStore.dispose); + provider.attach(emptyStore); + expect(provider.totalReadingSeconds, 0); + expect(provider.daysRead, 0); + expect(provider.booksRead, 0); + expect(provider.genreDistribution, isEmpty); + }); + + test('long history uses yearly buckets and custom boundaries clip cross-midnight sessions', () async { + final store = DataStore()..loadData(); + addTearDown(store.dispose); + final start = DateTime(2020, 1, 31, 23, 50); + + await store.commitTracking( + activities: [ + ReadingActivity( + id: 'cross-midnight', + bookId: 'book', + bookTitle: 'Book', + startTime: start, + endTime: start.add(const Duration(minutes: 20)), + createdAt: start, + pages: 8, + ), + ], + ); + + final provider = StatisticsProvider(now: () => DateTime(2026, 11, 2))..attach(store); + addTearDown(provider.dispose); + provider.setPeriod(StatsPeriod.allTime); + expect(provider.bucketSize, StatisticsBucketSize.year); + expect(provider.readingTimeData.length, 7); + expect(provider.readingTimeData.first.date, DateTime(2020, 1, 31)); + expect(provider.totalReadingSeconds, 1200); + expect(provider.periodDays, DateTime.utc(2026, 11, 3).difference(DateTime.utc(2020, 1, 31)).inDays); + provider.setCustomDateRange(DateTime(2020, 2, 1), DateTime(2020, 2, 1)); + expect(provider.totalReadingSeconds, 600); + expect(provider.daysRead, 1); + expect(provider.pagesRead, 8); + expect(provider.readingTimeData.single.seconds, 600); + expect(provider.sessionStats.totalSessions, 1); + }); + + test('reading days use the established five-minute threshold', () async { + final store = DataStore()..loadData(); + addTearDown(store.dispose); + + await store.commitTracking( + activities: [ + for (var day = 1; day <= 2; day++) + ReadingActivity( + id: '$day', + bookId: 'book', + bookTitle: 'Book', + startTime: DateTime(2026, 11, day), + endTime: DateTime(2026, 11, day, 0, day + 3), + createdAt: DateTime(2026, 11, day), + ), + ], + ); + + final provider = StatisticsProvider(now: () => DateTime(2026, 11, 2, 12))..attach(store); + addTearDown(provider.dispose); + provider.setPeriod(StatsPeriod.month); + expect(provider.daysRead, 1); + expect(provider.totalReadingMinutes, 9); + expect(provider.averageDailyMinutes, 5); + }); } diff --git a/app/test/reader/reading_activity_integration_test.dart b/app/test/reader/reading_activity_integration_test.dart new file mode 100644 index 00000000..f8ca0470 --- /dev/null +++ b/app/test/reader/reading_activity_integration_test.dart @@ -0,0 +1,123 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:archive/archive.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/reader/reading_activity_tracker.dart'; +import 'package:papyrus_reader/papyrus_reader.dart'; +import 'package:papyrus_reader/src/engine/epub/worker/epub_processor.dart'; +import 'package:papyrus_reader/src/engine/epub/worker/epub_worker.dart'; + +import 'reading_activity_tracker_test.dart' show RecordingRepository; + +void main() { + testWidgets('pinned reader records two distinct rendered EPUB pages', (tester) async { + var now = DateTime.utc(2026, 11, 2, 12); + final repository = RecordingRepository(); + final tracker = ReadingActivityTracker( + repository: repository, + book: Book(id: 'book', title: 'Book', author: '', addedAt: now, pageCount: 200), + shelfIds: [], + deviceId: 'one', + onError: (_) {}, + now: () => now, + checkpointInterval: const Duration(days: 1), + ); + final engine = EpubReaderEngine(workerFactory: () async => _InProcessWorker()); + addTearDown(engine.dispose); + await engine.load( + ReaderDocument(id: 'book', format: ReaderFormat.epub, loadBytes: () async => _book()), + preferences: const ReaderPreferences(columnMode: ReaderColumnMode.single, pageTurnAnimation: false), + ); + await tester.pumpWidget(MaterialApp(home: Builder(builder: engine.buildViewport))); + await tester.pumpAndSettle(); + + final first = engine.snapshot.coverage.single; + tracker.onActivity(_event(engine)); + now = now.add(const Duration(seconds: 10)); + await tracker.flush(); + final firstPages = repository.records.values.single.coverage.single; + expect(first.chapterCount, isNull); + expect(firstPages.estimated, isFalse); + expect((firstPages.end - firstPages.start) * firstPages.pagesPerUnit, 1); + var turned = false; + final turn = engine.goNext().then((_) => turned = true); + + for (var frame = 0; !turned && frame < 100; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + } + + expect(turned, isTrue); + await turn; + await tester.pumpAndSettle(); + final second = engine.snapshot.coverage.single; + expect(second.start, greaterThan(first.start)); + tracker.onActivity(_event(engine)); + now = now.add(const Duration(seconds: 10)); + await tracker.close(); + final coverage = repository.records.values.expand((activity) => activity.coverage).toList(); + expect(coverage, hasLength(2)); + + expect(coverage.map((extent) => extent.key).toSet(), hasLength(2)); + expect(coverage.every((extent) => !extent.estimated && extent.end - extent.start == 1), isTrue); + expect(coverage.fold(0, (sum, extent) => sum + extent.pagesPerUnit), 2); + }); +} + +ReaderActivityEvent _event(EpubReaderEngine engine) => ReaderActivityEvent( + ready: engine.snapshot.contentReady, + visible: true, + cause: ReaderNavigationCause.viewport, + coverage: engine.snapshot.coverage, +); + +Uint8List _book() { + final files = { + 'mimetype': 'application/epub+zip', + 'META-INF/container.xml': + '' + '', + 'content.opf': + '' + 'book' + 'Booken' + '' + '' + '', + 'toc.ncx': + '' + 'Book', + 'chapter.xhtml': + '' + '${List.filled(200, '

Reading across pages must preserve the amount of content actually viewed.

').join()}' + '', + }; + final archive = Archive(); + + for (final entry in files.entries) { + final bytes = utf8.encode(entry.value); + archive.addFile(ArchiveFile(entry.key, bytes.length, bytes)); + } + + return Uint8List.fromList(ZipEncoder().encode(archive)); +} + +class _InProcessWorker implements EpubWorker { + final _processor = EpubProcessor(); + + @override + Future> request(String command, Object? argument) async { + final response = await processEpubMessage(_processor, {'id': 1, 'command': command, 'argument': argument}); + + if (response['error'] != null) { + throw StateError('${response['error']}: ${response['message']}'); + } + + return Map.from(response['result'] as Map); + } + + @override + void dispose() {} +} diff --git a/app/test/reader/reading_activity_tracker_test.dart b/app/test/reader/reading_activity_tracker_test.dart index a59bab80..3a293a26 100644 --- a/app/test/reader/reading_activity_tracker_test.dart +++ b/app/test/reader/reading_activity_tracker_test.dart @@ -1,5 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/data/repositories/tracking_repository.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/providers/statistics_provider.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/models/reading_activity.dart'; import 'package:papyrus/models/reading_goal.dart'; @@ -123,6 +125,40 @@ void main() { }); }); + test('scrolling within a rendered page retains its exposure timer', () async { + var now = start; + final repository = RecordingRepository(); + final tracker = ReadingActivityTracker( + repository: repository, + book: Book(id: 'book', title: 'Book', author: '', addedAt: start), + shelfIds: [], + deviceId: 'one', + onError: (_) {}, + now: () => now, + checkpointInterval: const Duration(days: 1), + ); + + tracker.onActivity( + event( + coverage: const [ + ReaderContentCoverage(key: 'epub:0:100:1000', start: .1, end: .2), + ], + ), + ); + now = now.add(const Duration(seconds: 6)); + tracker.onActivity( + event( + coverage: const [ + ReaderContentCoverage(key: 'epub:0:100:1000', start: .15, end: .3), + ], + ), + ); + now = now.add(const Duration(seconds: 4)); + await tracker.close(); + expect(repository.records.values.first.coverage, isEmpty); + expect(repository.records.values.last.coverage.single.pagesPerUnit, 1); + }); + test('write retries reuse UUIDs and captured profiles cannot write into another library', () async { var now = start; final repository = RecordingRepository()..failures = 1; @@ -151,7 +187,44 @@ void main() { expect(repository.records.length, 1); }); - test('EPUB metadata calibration is estimated; no metadata still records time', () async { + test('chapter coverage preserves calibrated ranges and resets exposure on a turn', () async { + for (final pageCount in [null, 200]) { + var now = start; + final repository = RecordingRepository(); + final tracker = ReadingActivityTracker( + repository: repository, + book: Book(id: 'book', title: 'Book', author: '', addedAt: start, pageCount: pageCount), + shelfIds: [], + deviceId: 'one', + onError: (_) {}, + now: () => now, + checkpointInterval: const Duration(days: 1), + ); + + for (final (from, to, seconds) in [(0.0, .1, 10), (.1, .2, 9), (.2, .3, 10)]) { + tracker.onActivity( + event( + coverage: [ + ReaderContentCoverage(key: 'epub:0:1000', start: from, end: to, chapterCount: 10), + ], + ), + ); + now = now.add(Duration(seconds: seconds)); + } + + await tracker.close(); + final coverage = repository.records.values.expand((activity) => activity.coverage).toList(); + + if (pageCount == null) { + expect(coverage, isEmpty); + } else { + expect(coverage.map((extent) => (extent.start, extent.end)), [(0.0, .1), (.2, .3)]); + expect(coverage.every((extent) => extent.estimated && extent.pagesPerUnit == 20), isTrue); + } + } + }); + + test('EPUB rendered pages count without metadata and checkpoints do not duplicate them', () async { for (final count in [null, 200]) { var now = start; final repository = RecordingRepository(); @@ -167,19 +240,29 @@ void main() { ); tracker.onActivity( - event(coverage: const [ReaderContentCoverage(key: 'epub:0:1000', start: .1, end: .3, chapterCount: 10)]), + event( + coverage: const [ + ReaderContentCoverage(key: 'epub:0:100:1000', start: .1, end: .3), + ReaderContentCoverage(key: 'epub:0:300:1000', start: .3, end: .5), + ], + ), ); + now = now.add(const Duration(seconds: 10)); + await tracker.flush(); now = now.add(const Duration(seconds: 10)); await tracker.close(); - final activity = repository.records.values.single; - expect(activity.seconds, 10); - expect(activity.coverage.length, count == null ? 0 : 1); - - if (count != null) { - expect(activity.coverage.single.pagesPerUnit, 20); - expect(activity.isEstimated, isTrue); - } + expect(repository.records.values.first.coverage.length, 2); + expect(repository.records.values.every((activity) => !activity.isEstimated), isTrue); + final store = DataStore()..loadData(); + addTearDown(store.dispose); + await store.commitTracking(activities: repository.records.values.toList()); + final statistics = StatisticsProvider(now: () => now)..attach(store); + addTearDown(statistics.dispose); + expect(statistics.totalReadingSeconds, 20); + expect(statistics.pagesRead, 2); + expect(statistics.hasEstimatedPages, isFalse); + expect(statistics.readingTimeData.fold(0, (sum, day) => sum + day.pages), 2); } }); } 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..0e95764d --- /dev/null +++ b/app/test/services/reading_timer_notifications_test.dart @@ -0,0 +1,235 @@ +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('failed initial loads preserve native actions until the timer is restored', () async { + final original = timer.session!; + Map? pending = batch(original, [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; + }); + + timer.dispose(); + library.trackingRepository.failLoad = true; + timer = ReadingTimerProvider(store, now: () => now, tick: false); + final notifications = ReadingTimerNotifications(timer, onOpen: () {}, channel: channel, supported: true); + addTearDown(notifications.dispose); + await pumpEventQueue(); + expect(timer.loading, isFalse); + expect(timer.error, isNotNull); + expect(acknowledgements, 0); + expect(timer.available, isFalse); + expect(await timer.applyNotificationActions(pending!), isFalse); + await timer.reload(); + await pumpEventQueue(); + expect(acknowledgements, 0); + expect(pending, isNotNull); + library.trackingRepository.failLoad = false; + now = start.add(const Duration(minutes: 3)); + await timer.reload(); + await pumpEventQueue(); + expect(timer.available, isTrue); + expect(acknowledgements, 1); + expect(pending, isNull); + expect(timer.running, isFalse); + 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..4ac0490f --- /dev/null +++ b/app/test/timer/timer_test_library.dart @@ -0,0 +1,78 @@ +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; + bool failLoad = false; + int saves = 0; + Completer? writeGate; + Completer? loadGate; + final changes = StreamController.broadcast(); + + @override + Future loadTimer() async { + if (failLoad) { + throw StateError('Load failed. Try again.'); + } + + return 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/updates/update_controller_test.dart b/app/test/updates/update_controller_test.dart index 437d0851..a7e33edd 100644 --- a/app/test/updates/update_controller_test.dart +++ b/app/test/updates/update_controller_test.dart @@ -187,6 +187,33 @@ void main() { expect(updates.busy, isFalse); }); + test('last successful check survives failure and does not claim freshness after restart', () async { + var fails = false; + final updates = controller( + manifest: () async { + if (fails) { + throw StateError('Offline'); + } + + return metadata(build: 6); + }, + ); + + expect(updates.status, UpdateStatus.unchecked); + expect(updates.lastChecked, isNull); + await updates.check(); + expect(updates.status, UpdateStatus.upToDate); + final checked = updates.lastChecked; + expect(checked, isNotNull); + fails = true; + await updates.check(); + expect(updates.status, UpdateStatus.checkFailed); + expect(updates.lastChecked, checked); + final restarted = controller(); + expect(restarted.lastChecked, checked); + expect(restarted.status, UpdateStatus.unchecked); + }); + test('invalid release identity fails closed', () { for (final data in [ {...metadata(), 'tag': 'v0.2.0+8'}, diff --git a/app/test/updates/update_settings_test.dart b/app/test/updates/update_settings_test.dart index b15bbd60..d6776aaa 100644 --- a/app/test/updates/update_settings_test.dart +++ b/app/test/updates/update_settings_test.dart @@ -7,6 +7,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:papyrus/updates/update_controller.dart'; +import 'package:papyrus/pages/app_updates_page.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; import 'package:papyrus/themes/app_theme.dart'; import 'package:papyrus/updates/update_release.dart'; import 'package:papyrus/updates/update_settings.dart'; @@ -14,12 +16,14 @@ import 'package:shared_preferences/shared_preferences.dart'; void main() { for (final eink in [false, true]) { - testWidgets('automatic toggle stays aligned and has no pressed ink, eink=$eink', (tester) async { + testWidgets('automatic toggle has no pressed ink and Back releases the settings guard, eink=$eink', (tester) async { SharedPreferences.setMockInitialValues({'updates.automatic': false}); var checks = 0; final preferences = await SharedPreferences.getInstance(); final updates = UpdateController( preferences: preferences, + appInfo: () async => + PackageInfo(appName: 'Papyrus', packageName: 'papyrus', version: '0.2.0', buildNumber: '6'), channelProvider: () async { checks++; return UpdateChannel.windows; @@ -27,10 +31,7 @@ void main() { ); addTearDown(updates.dispose); - const status = UpdateController.checkFailureMessage; - updates.message = status; final boundaryKey = GlobalKey(); - await tester.pumpWidget( RepaintBoundary( key: boundaryKey, @@ -50,9 +51,14 @@ void main() { await tester.tap(find.text('Open updates')); await tester.pumpAndSettle(); - final dialog = tester.getRect(find.byType(AlertDialog)); + expect(find.byType(AlertDialog), findsNothing); + final route = ModalRoute.of(tester.element(find.byType(AppUpdatesPage)))! as TransitionRoute; + expect(route.transitionDuration, Duration.zero); + expect(route.reverseTransitionDuration, Duration.zero); + expect(find.text('0.2.0 (6)'), findsOneWidget); + expect(find.text('Not checked yet'), findsOneWidget); + expect(updates.viewingSettings, isTrue); final label = tester.getTopLeft(find.text('Check automatically')); - expect(label.dx, tester.getTopLeft(find.text(status).hitTestable()).dx); final before = await capture(tester, boundaryKey); final gesture = await tester.startGesture(tester.getCenter(find.byType(Switch))); await tester.pump(const Duration(milliseconds: 100)); @@ -60,25 +66,27 @@ void main() { await gesture.up(); await tester.pumpAndSettle(); expect(preferences.getBool('updates.automatic'), isTrue); - expect(tester.widget(find.byType(Switch)).value, isTrue); - expect(find.text(status).hitTestable(), findsOneWidget); - expect(tester.getRect(find.byType(AlertDialog)), dialog); expect(tester.getTopLeft(find.text('Check automatically')), label); expect(checks, 0); - await tester.tap(find.text('Check automatically')); + await tester.tap(find.byType(BackButton)); await tester.pumpAndSettle(); - expect(preferences.getBool('updates.automatic'), isFalse); - expect(tester.getRect(find.byType(AlertDialog)), dialog); - expect(checks, 0); + expect(find.text('Open updates'), findsOneWidget); + expect(updates.viewingSettings, isFalse); }); } - for (final (width, scale, eink) in [(800.0, 1.0, false), (360.0, 1.0, false), (360.0, 2.0, true)]) { - testWidgets('Check now keeps status and controls in place at width $width, scale $scale', (tester) async { + for (final (width, scale, eink) in [ + (1280.0, 1.0, false), + (360.0, 1.0, false), + (320.0, 2.0, false), + (360.0, 2.0, true), + ]) { + testWidgets('check, failure, retry and success stay usable at width $width, scale $scale, eink=$eink', ( + tester, + ) async { tester.view.physicalSize = Size(width, 900); tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.reset); SharedPreferences.setMockInitialValues({}); var result = Completer(); final updates = UpdateController( @@ -97,49 +105,33 @@ void main() { data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(scale)), child: child!, ), - home: Scaffold( - body: Builder( - builder: (context) => TextButton( - onPressed: () => showUpdateSettings(context, updates), - child: const Text('Open updates'), - ), - ), - ), + home: AppUpdatesPage(controller: updates), ), ); - await tester.tap(find.text('Open updates')); await tester.pumpAndSettle(); final checkButton = find.byKey(const ValueKey('check-updates')); - var dialog = tester.getRect(find.byType(AlertDialog)); - var toggle = tester.getRect(find.byType(Switch)); - var check = tester.getRect(checkButton); - - void expectStableLayout() { - expect(tester.getRect(find.byType(AlertDialog)), dialog); - expect(tester.getRect(find.byType(Switch)), toggle); - expect(tester.getRect(checkButton), check); - expect(tester.takeException(), isNull); - } for (final fails in [true, false]) { - final message = updates.message; - dialog = tester.getRect(find.byType(AlertDialog)); - toggle = tester.getRect(find.byType(Switch)); - check = tester.getRect(checkButton); - final statusBottom = tester.getBottomLeft(find.text(message)).dy; - final toggleTop = tester - .getTopLeft(find.ancestor(of: find.byType(Switch), matching: find.byType(Theme)).first) - .dy; - expect(toggleTop - statusBottom, 8); - await tester.tap(find.text('Check now')); + await tester.ensureVisible(checkButton); + final buttonBounds = tester.getRect(checkButton); + final buttonOffset = buttonBounds.top - tester.getTopLeft(find.text('Installed version')).dy; + final toggleBounds = tester.getRect(find.byType(Switch)); + final previousStatus = fails ? 'Not checked yet' : 'Couldn’t check for updates'; + await tester.tap(checkButton); await tester.pump(); - expect(find.text('Checking…'), findsOneWidget); - expect(find.text(message), findsOneWidget); + expect(find.text(previousStatus), findsOneWidget); + expect(find.byType(AppLinearProgressIndicator), findsNothing); expect(tester.widget(checkButton).onPressed, isNull); - expectStableLayout(); - await tester.pump(const Duration(milliseconds: 200)); - expectStableLayout(); + expect(tester.getRect(checkButton), buttonBounds); + expect(tester.getRect(find.byType(Switch)), toggleBounds); + await tester.pump(const Duration(milliseconds: 350)); + expect(find.text('Checking…'), findsOneWidget); + expect(find.text(previousStatus), findsOneWidget); + expect(find.byType(AppLinearProgressIndicator), findsOneWidget); + expect(tester.getRect(checkButton), buttonBounds); + expect(tester.getRect(find.byType(Switch)), toggleBounds); + expect(tester.takeException(), isNull); if (fails) { result.completeError(StateError('Offline')); @@ -148,18 +140,165 @@ void main() { } await tester.pumpAndSettle(); - expect( - find.text(fails ? UpdateController.checkFailureMessage : 'Papyrus is up to date.').hitTestable(), - findsOneWidget, - ); - expect(find.text('Check now'), findsOneWidget); + expect(find.text(fails ? 'Couldn’t check for updates' : 'You’re up to date'), findsOneWidget); + expect(find.text(fails ? 'Retry' : 'Check for updates'), findsOneWidget); + expect(find.textContaining('Last checked'), fails ? findsNothing : findsOneWidget); expect(tester.widget(checkButton).onPressed, isNotNull); + final action = tester.element(find.text(fails ? 'Retry' : 'Check for updates')); + expect(DefaultTextStyle.of(action).style.color, Theme.of(action).colorScheme.onPrimary); expect(tester.takeException(), isNull); result = Completer(); + + if (fails) { + expect( + tester.getRect(checkButton).top - tester.getTopLeft(find.text('Installed version')).dy, + buttonOffset, + ); + + await tester.ensureVisible(checkButton); + final retryBounds = tester.getRect(checkButton); + final retryToggleBounds = tester.getRect(find.byType(Switch)); + await tester.tap(checkButton); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('Retry'), findsOneWidget); + expect(find.text('Couldn’t check for updates'), findsOneWidget); + expect(find.text('Please try again.'), findsNothing); + expect(find.byType(AppLinearProgressIndicator), findsNothing); + expect(tester.getRect(checkButton), retryBounds); + expect(tester.getRect(find.byType(Switch)), retryToggleBounds); + final retry = tester.element(find.text('Retry')); + expect(DefaultTextStyle.of(retry).style.color, Theme.of(retry).colorScheme.onPrimary); + result.completeError(StateError('Still offline')); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 350)); + expect(find.byType(AppLinearProgressIndicator), findsNothing); + expect(find.text('Retry'), findsOneWidget); + expect(tester.getRect(checkButton), retryBounds); + expect(tester.getRect(find.byType(Switch)), retryToggleBounds); + result = Completer(); + } } }); } + testWidgets('download progress, retry and staged guidance preserve the automatic setting', (tester) async { + SharedPreferences.setMockInitialValues({}); + var attempt = 0; + final download = Completer(); + final updates = UpdateController( + preferences: await SharedPreferences.getInstance(), + appInfo: () async => PackageInfo(appName: 'Papyrus', packageName: 'papyrus', version: '0.2.0', buildNumber: '6'), + channelProvider: () async => UpdateChannel.windows, + manifest: () async => { + 'tag': 'v0.3.0+7', + 'version': '0.3.0', + 'build_number': 7, + 'assets': { + 'installer': {'name': 'papyrus-v0.3.0+7-windows-x64-setup.exe', 'sha256': 'a' * 64}, + }, + }, + installer: (_, progress) async { + if (attempt++ == 0) { + throw StateError('Disk full'); + } + + progress(0.5); + return download.future; + }, + ); + + addTearDown(updates.dispose); + await updates.check(); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.eink, + home: AppUpdatesPage(controller: updates), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Version 0.3.0 (7) is available'), findsOneWidget); + await tester.tap(find.text('Update')); + await tester.pumpAndSettle(); + expect(find.text('Couldn’t finish the update'), findsOneWidget); + await tester.tap(find.text('Retry')); + await tester.pump(); + expect(find.text('Downloading update: 50%'), findsOneWidget); + expect(tester.widget(find.byType(LinearProgressIndicator)).value, 0.5); + expect(tester.widget(find.byType(FilledButton)).onPressed, isNull); + download.complete('Close Papyrus to update'); + await tester.pumpAndSettle(); + expect(find.text('Ready to finish updating'), findsOneWidget); + expect(find.text('Close Papyrus to update'), findsOneWidget); + expect(find.byType(FilledButton), findsNothing); + expect(find.text('Check automatically'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + for (final (channel, label) in [ + (UpdateChannel.web, 'Reload'), + (UpdateChannel.apt, 'Repository setup'), + (UpdateChannel.manual, 'Downloads'), + ]) { + testWidgets('$channel offers the correct platform action', (tester) async { + SharedPreferences.setMockInitialValues({}); + final updates = UpdateController( + preferences: await SharedPreferences.getInstance(), + appInfo: () async => + PackageInfo(appName: 'Papyrus', packageName: 'papyrus', version: '0.2.0', buildNumber: '6'), + channelProvider: () async => channel, + manifest: () async => {'tag': 'v0.3.0+7', 'version': '0.3.0', 'build_number': 7}, + ); + + addTearDown(updates.dispose); + await updates.check(); + await tester.pumpWidget(MaterialApp(home: AppUpdatesPage(controller: updates))); + await tester.pumpAndSettle(); + expect(find.widgetWithText(FilledButton, label), findsOneWidget); + expect(find.text('Update'), findsNothing); + }); + } + + testWidgets('Back during a check allows reopening without losing the result', (tester) async { + SharedPreferences.setMockInitialValues({}); + final result = Completer(); + final updates = UpdateController( + preferences: await SharedPreferences.getInstance(), + appInfo: () async => PackageInfo(appName: 'Papyrus', packageName: 'papyrus', version: '0.2.0', buildNumber: '6'), + channelProvider: () async => UpdateChannel.play, + playCheck: () => result.future, + ); + + addTearDown(updates.dispose); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.eink, + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => showUpdateSettings(context, updates), + child: const Text('Open updates'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open updates')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Check for updates')); + await tester.pump(); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(updates.viewingSettings, isFalse); + result.complete(null); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open updates')); + await tester.pumpAndSettle(); + expect(find.text('You’re up to date'), findsOneWidget); + expect(find.textContaining('Last checked'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + for (final scale in [1.0, 2.0]) { testWidgets('notice and dialog work on a narrow e-ink screen at text scale $scale', (tester) async { tester.view.physicalSize = const Size(360, 780); diff --git a/app/test/widgets/library/card_overlay_contrast_test.dart b/app/test/widgets/library/card_overlay_contrast_test.dart new file mode 100644 index 00000000..75007eca --- /dev/null +++ b/app/test/widgets/library/card_overlay_contrast_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/acquisition/acquisition_models.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/widgets/library/acquisition_placeholder_card.dart'; +import 'package:papyrus/widgets/library/book_card.dart'; + +import '../../helpers/test_helpers.dart'; + +void main() { + final themes = { + 'light': AppTheme.light, + 'dark': AppTheme.dark, + 'high contrast light': AppTheme.highContrastLight, + 'high contrast dark': AppTheme.highContrastDark, + 'e-ink': AppTheme.eink, + }; + + final job = AcquisitionJob.fromJson({ + 'job_id': 'contrast-job', + 'title': 'Downloading book', + 'status': 'queued', + }); + + final cards = { + 'favorite': BookCard(book: buildTestBook(), isFavorite: true), + 'selected book': BookCard( + book: buildTestBook(), + isFavorite: false, + isSelectionMode: true, + isSelected: true, + ), + 'selected download': AcquisitionPlaceholderCard(job: job, isSelectionMode: true, isSelected: true), + 'unselected book': BookCard(book: buildTestBook(), isFavorite: false, isSelectionMode: true), + 'unselected download': AcquisitionPlaceholderCard(job: job, isSelectionMode: true), + }; + + for (final theme in themes.entries) { + for (final card in cards.entries) { + testWidgets('${card.key} stays visible over cover colors in ${theme.key}', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: theme.value, + home: Scaffold( + body: SizedBox(width: 200, height: 300, child: card.value), + ), + ), + ); + + final icon = switch (card.key) { + 'favorite' => Icons.favorite, + 'selected book' || 'selected download' => Icons.check_circle, + _ => Icons.radio_button_unchecked, + }; + + final finder = find.byIcon(icon); + final foreground = tester.widget(finder).color!; + final material = tester.widget(find.ancestor(of: finder, matching: find.byType(Material)).first); + + for (final cover in [Colors.white, Colors.black, Colors.red, Colors.green, Colors.blue]) { + final background = Color.alphaBlend(material.color!, cover); + final foregroundLuminance = foreground.computeLuminance(); + final backgroundLuminance = background.computeLuminance(); + final lighter = foregroundLuminance > backgroundLuminance ? foregroundLuminance : backgroundLuminance; + final darker = foregroundLuminance < backgroundLuminance ? foregroundLuminance : backgroundLuminance; + expect((lighter + 0.05) / (darker + 0.05), greaterThanOrEqualTo(3), reason: 'Cover: $cover'); + } + + expect(tester.takeException(), isNull); + }); + } + } +} diff --git a/app/test/widgets/profile/about_settings_section_test.dart b/app/test/widgets/profile/about_settings_section_test.dart index 399c7502..6f5bd655 100644 --- a/app/test/widgets/profile/about_settings_section_test.dart +++ b/app/test/widgets/profile/about_settings_section_test.dart @@ -1,8 +1,13 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:papyrus/pages/licenses_page.dart'; +import 'package:papyrus/services/license_packages.dart'; import 'package:papyrus/widgets/profile/about_settings_section.dart'; void main() { @@ -84,4 +89,50 @@ void main() { await tester.pumpAndSettle(); expect(find.textContaining('Could not open'), findsOneWidget); }); + + testWidgets('opening licenses keeps About visible until the list is ready', (tester) async { + final license = Completer(); + LicenseRegistry.reset(); + LicenseRegistry.addLicense(() => Stream.fromFuture(license.future)); + addTearDown(LicenseRegistry.reset); + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => const Scaffold(body: AboutSettingsSection()), + ), + GoRoute( + path: '/profile/licenses', + pageBuilder: (context, state) => NoTransitionPage( + child: LicensesPage(initialLicenses: state.extra as LicensePackages?), + ), + ), + ], + ); + + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Licenses')); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.byType(AboutSettingsSection), findsOneWidget); + expect(find.byType(LicensesPage), findsNothing); + license.complete(const LicenseEntryWithLineBreaks(['alpha'], 'Alpha license text')); + + for (var frame = 0; frame < 3; frame++) { + await tester.pump(); + + if (find.byType(LicensesPage).evaluate().isNotEmpty) { + expect(find.widgetWithText(ListTile, 'alpha'), findsOneWidget); + expect(find.text('Loading licenses…'), findsNothing); + } + } + + expect(find.byType(LicensesPage), findsOneWidget); + await tester.pageBack(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Licenses')); + await tester.pumpAndSettle(); + expect(find.widgetWithText(ListTile, 'alpha'), findsOneWidget); + }); } diff --git a/app/test/widgets/profile/appearance_settings_section_test.dart b/app/test/widgets/profile/appearance_settings_section_test.dart index befe7d66..288d6b15 100644 --- a/app/test/widgets/profile/appearance_settings_section_test.dart +++ b/app/test/widgets/profile/appearance_settings_section_test.dart @@ -13,6 +13,7 @@ void main() { SharedPreferences.setMockInitialValues({'theme_mode': 'light'}); final prefs = await SharedPreferences.getInstance(); prefsProvider = PreferencesProvider(prefs); + addTearDown(prefsProvider.dispose); }); Widget buildSection({bool isDesktop = false}) { @@ -24,6 +25,26 @@ void main() { ); } + for (final isDesktop in [false, true]) { + testWidgets('page alignment picker saves both choices on ${isDesktop ? "desktop" : "mobile"}', (tester) async { + await tester.pumpWidget(buildSection(isDesktop: isDesktop)); + expect(find.text('Left'), findsOneWidget); + await tester.tap(find.text('Page alignment')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Center')); + await tester.pumpAndSettle(); + expect(prefsProvider.pageAlignment, 'center'); + expect(find.text('Center'), findsOneWidget); + await tester.tap(find.text('Page alignment')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Left')); + await tester.pumpAndSettle(); + expect(prefsProvider.pageAlignment, 'left'); + expect(find.text('Left'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + testWidgets('renders mobile appearance settings and opens theme picker', (tester) async { await tester.pumpWidget(buildSection(isDesktop: false)); expect(find.text('Appearance'), findsOneWidget); 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/app/test/widgets/shared/page_empty_states_test.dart b/app/test/widgets/shared/page_empty_states_test.dart index f2677317..61eef942 100644 --- a/app/test/widgets/shared/page_empty_states_test.dart +++ b/app/test/widgets/shared/page_empty_states_test.dart @@ -82,7 +82,7 @@ void main() { 'No book in progress': ContinueReadingCard(), 'No reading goals set': ReadingGoalCard(goals: []), 'No books added recently': RecentlyAddedSection(books: []), - 'No reading data for this period': ReadingTimeBarChart(activities: []), + 'No reading data for this period': ReadingActivityBarChart(activities: []), 'No books read data available': BooksPerMonthChart(monthlyStats: []), }; diff --git a/app/test/widgets/statistics/reading_charts_test.dart b/app/test/widgets/statistics/reading_charts_test.dart new file mode 100644 index 00000000..cf978db9 --- /dev/null +++ b/app/test/widgets/statistics/reading_charts_test.dart @@ -0,0 +1,175 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/providers/statistics_provider.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/widgets/statistics/reading_charts.dart'; + +void main() { + final activities = List.generate(11, (index) { + final date = DateTime(2026, 10, 23 + index); + return StatisticsActivity( + date: date, + endDate: DateTime(date.year, date.month, date.day + 1), + seconds: index == 0 ? 49 : (index == 1 ? 7 : 0), + pages: 0, + bucketSize: StatisticsBucketSize.day, + booksRead: const [], + ); + }); + + Future pumpChart( + WidgetTester tester, + double width, { + List? data, + ReadingActivityMetric metric = ReadingActivityMetric.time, + }) async { + tester.view.physicalSize = Size(width, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark, + home: Scaffold( + body: ReadingActivityBarChart(activities: data ?? activities, metric: metric, isDesktop: width > 600), + ), + ), + ); + + await tester.pumpAndSettle(); + } + + testWidgets('sub-minute sessions have a readable seconds scale and intermediate grid lines', (tester) async { + await pumpChart(tester, 800); + final data = tester.widget(find.byType(BarChart)).data; + expect(find.text('15s'), findsOneWidget); + expect(find.text('30s'), findsOneWidget); + expect(find.text('45s'), findsOneWidget); + expect(find.text('1m'), findsOneWidget); + expect(data.gridData.show, isTrue); + expect(data.maxY / data.gridData.horizontalInterval!, greaterThanOrEqualTo(3)); + expect(data.borderData.show, isTrue); + expect(data.barGroups.first.barRods.first.toY, closeTo(49 / 60, .0001)); + expect(tester.takeException(), isNull); + }); + + testWidgets('wide daily charts label every day including both range boundaries', (tester) async { + await pumpChart(tester, 1200); + expect(find.text('Oct 23'), findsOneWidget); + expect(find.text('Nov 2'), findsOneWidget); + + for (final day in ['24', '25', '26', '27', '28', '29', '30', '31', 'Nov 1']) { + expect(find.text(day), findsOneWidget); + } + + expect(tester.takeException(), isNull); + }); + + testWidgets('narrow charts retain the latest date and a tick for every bucket', (tester) async { + await pumpChart(tester, 300); + expect(find.text('Nov 2'), findsOneWidget); + expect(find.text('24'), findsNothing); + + for (var index = 0; index < activities.length; index++) { + expect(find.byKey(ValueKey('activity-axis-tick-$index')), findsOneWidget); + } + expect(tester.takeException(), isNull); + }); + testWidgets('monthly axis labels stay separated on desktop and phone', (tester) async { + final months = List.generate(23, (index) { + final date = DateTime(2025, 1 + index); + return StatisticsActivity( + date: date, + endDate: DateTime(date.year, date.month + 1), + seconds: index == 0 ? 3600 : 0, + pages: 0, + bucketSize: StatisticsBucketSize.month, + booksRead: const [], + ); + }); + + for (final width in [650.0, 360.0]) { + await pumpChart(tester, width, data: months); + expect(find.text('Nov 26'), findsOneWidget); + final labels = find.byWidgetPredicate( + (widget) => widget is Text && RegExp(r'^[A-Z][a-z]{2} \d{2}$').hasMatch(widget.data ?? ''), + ); + final rectangles = labels.evaluate().map((element) => tester.getRect(find.byWidget(element.widget))).toList() + ..sort((left, right) => left.left.compareTo(right.left)); + + final interval = rectangles[1].center.dx - rectangles[0].center.dx; + + for (var index = 1; index < rectangles.length; index++) { + expect(rectangles[index].left - rectangles[index - 1].right, greaterThanOrEqualTo(8)); + expect(rectangles[index].center.dx - rectangles[index - 1].center.dx, closeTo(interval, 0.1)); + } + + expect(tester.takeException(), isNull); + } + }); + testWidgets('Time and Pages use identical centered bars and complete grid boundaries', (tester) async { + final activity = StatisticsActivity( + date: DateTime(2026, 11, 2), + endDate: DateTime(2026, 11, 3), + seconds: 70, + pages: 2, + bucketSize: StatisticsBucketSize.day, + booksRead: const [], + ); + final bars = []; + + for (final metric in ReadingActivityMetric.values) { + await pumpChart(tester, 800, data: [activity], metric: metric); + final data = tester.widget(find.byType(BarChart)).data; + bars.add(data.barGroups.single.barRods.single); + expect(find.byType(LineChart), findsNothing); + expect(data.borderData.border.top.style, BorderStyle.solid); + expect(data.borderData.border.top.width, greaterThan(0)); + expect(data.borderData.border.bottom.width, greaterThan(0)); + expect(tester.getCenter(find.text('Mon')).dx, closeTo(420, 1)); + expect(find.text(metric == ReadingActivityMetric.time ? '1.5m' : '3'), findsOneWidget); + } + + expect(bars[0].width, bars[1].width); + expect(bars[0].color, bars[1].color); + expect(bars[0].borderRadius, bars[1].borderRadius); + expect(bars[0].toY, closeTo(70 / 60, .0001)); + expect(bars[1].toY, 2); + }); + + testWidgets('24-day ranges have uniform label intervals with no missing bucket ticks', (tester) async { + final days = List.generate(24, (index) { + final date = DateTime(2026, 10, 10 + index); + return StatisticsActivity( + date: date, + endDate: DateTime(date.year, date.month, date.day + 1), + seconds: index == 23 ? 70 : 0, + pages: 0, + bucketSize: StatisticsBucketSize.day, + booksRead: const [], + ); + }); + + for (final width in [700.0, 350.0]) { + await pumpChart(tester, width, data: days); + final labels = find.byWidgetPredicate( + (widget) => widget is Text && RegExp(r'^(Oct \d+|Nov \d+|\d{2})$').hasMatch(widget.data ?? ''), + ); + final positions = labels.evaluate().map((element) => tester.getCenter(find.byWidget(element.widget)).dx).toList() + ..sort(); + final interval = positions[1] - positions[0]; + + for (var index = 1; index < positions.length; index++) { + expect(positions[index] - positions[index - 1], closeTo(interval, 0.1)); + } + + for (var index = 0; index < days.length; index++) { + expect(find.byKey(ValueKey('activity-axis-tick-$index')), findsOneWidget); + } + + expect(find.text('Nov 2'), findsOneWidget); + expect(tester.takeException(), isNull); + } + }); +} 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. diff --git a/docs/about-and-support.md b/docs/about-and-support.md index ce13d922..9efeb401 100644 --- a/docs/about-and-support.md +++ b/docs/about-and-support.md @@ -1,9 +1,20 @@ # About and support +On phones, Settings opens a grouped index. Account, Appearance, Storage, +Accessibility, and About each open their own page. Desktop Settings retains its +sidebar. Settings navigation is immediate, including Back. + +About → App updates opens a dedicated page with the installed version, latest +check result, automatic-check preference, and the action appropriate to the +installation channel. Fast retries preserve the previous result while checking, +and longer checks show progress without moving the action. + About reads the installed package version and build number. What's new opens the matching `v+` release in `PapyrusReader/client`, using the release notes generated by the release workflow. Development versions without a published tag do not have release notes yet. Licenses are bundled with the app and work offline. +About prepares the license list before opening it to avoid a blank first frame. +Direct license routes retain delayed loading feedback and retry on failure. Support submits a bug report or feature request to the selected Papyrus server. No GitHub account is required. Its text, attachments, and app @@ -14,3 +25,6 @@ keep the current form text and selected files, and uncertain submissions are not Attach up to five images or videos, 10 MB per file. PNG, JPEG, GIF, WebP, MP4, MOV, and WebM are supported. Files can be removed before submission and upload only when Submit is pressed. Images and videos are embedded in the GitHub issue. + +The Support form follows Appearance → Page alignment (Left or Center). Changing +alignment preserves the draft and selected attachments. diff --git a/docs/images/card-contrast/dark.png b/docs/images/card-contrast/dark.png new file mode 100644 index 00000000..f22ecc29 Binary files /dev/null and b/docs/images/card-contrast/dark.png differ diff --git a/docs/images/card-contrast/eink.png b/docs/images/card-contrast/eink.png new file mode 100644 index 00000000..1dff02e7 Binary files /dev/null and b/docs/images/card-contrast/eink.png differ diff --git a/docs/images/card-contrast/high-contrast-dark.png b/docs/images/card-contrast/high-contrast-dark.png new file mode 100644 index 00000000..951e3ed8 Binary files /dev/null and b/docs/images/card-contrast/high-contrast-dark.png differ diff --git a/docs/images/card-contrast/high-contrast-light.png b/docs/images/card-contrast/high-contrast-light.png new file mode 100644 index 00000000..db93db0e Binary files /dev/null and b/docs/images/card-contrast/high-contrast-light.png differ diff --git a/docs/images/card-contrast/light.png b/docs/images/card-contrast/light.png new file mode 100644 index 00000000..c1b02d36 Binary files /dev/null and b/docs/images/card-contrast/light.png differ diff --git a/docs/images/folders-catalogs/catalogs-desktop.png b/docs/images/folders-catalogs/catalogs-desktop.png new file mode 100644 index 00000000..f26a37b2 Binary files /dev/null and b/docs/images/folders-catalogs/catalogs-desktop.png differ diff --git a/docs/images/folders-catalogs/catalogs-mobile.png b/docs/images/folders-catalogs/catalogs-mobile.png new file mode 100644 index 00000000..17189212 Binary files /dev/null and b/docs/images/folders-catalogs/catalogs-mobile.png differ diff --git a/docs/images/folders-catalogs/details-mobile.png b/docs/images/folders-catalogs/details-mobile.png new file mode 100644 index 00000000..60156736 Binary files /dev/null and b/docs/images/folders-catalogs/details-mobile.png differ diff --git a/docs/images/folders-catalogs/edit-desktop.png b/docs/images/folders-catalogs/edit-desktop.png new file mode 100644 index 00000000..e08cc6c9 Binary files /dev/null and b/docs/images/folders-catalogs/edit-desktop.png differ diff --git a/docs/images/folders-catalogs/folders-desktop.png b/docs/images/folders-catalogs/folders-desktop.png new file mode 100644 index 00000000..72b4fca3 Binary files /dev/null and b/docs/images/folders-catalogs/folders-desktop.png differ diff --git a/docs/images/folders-catalogs/folders-mobile.png b/docs/images/folders-catalogs/folders-mobile.png new file mode 100644 index 00000000..a6e79935 Binary files /dev/null and b/docs/images/folders-catalogs/folders-mobile.png differ diff --git a/docs/images/folders-catalogs/reconnect-desktop.png b/docs/images/folders-catalogs/reconnect-desktop.png new file mode 100644 index 00000000..c58d6dca Binary files /dev/null and b/docs/images/folders-catalogs/reconnect-desktop.png differ diff --git a/docs/images/settings/licenses-scrolled.png b/docs/images/settings/licenses-scrolled.png new file mode 100644 index 00000000..db9ee57a Binary files /dev/null and b/docs/images/settings/licenses-scrolled.png differ diff --git a/docs/images/settings/mobile-appearance.png b/docs/images/settings/mobile-appearance.png new file mode 100644 index 00000000..00258a9b Binary files /dev/null and b/docs/images/settings/mobile-appearance.png differ diff --git a/docs/images/settings/mobile-index.png b/docs/images/settings/mobile-index.png new file mode 100644 index 00000000..e92ce628 Binary files /dev/null and b/docs/images/settings/mobile-index.png differ diff --git a/docs/images/settings/storage-wide.png b/docs/images/settings/storage-wide.png new file mode 100644 index 00000000..ef9763a4 Binary files /dev/null and b/docs/images/settings/storage-wide.png differ diff --git a/docs/images/settings/updates-retry.png b/docs/images/settings/updates-retry.png new file mode 100644 index 00000000..172fb198 Binary files /dev/null and b/docs/images/settings/updates-retry.png differ diff --git a/tools/play-requirements.txt b/tools/play-requirements.txt index e898f44c..c4cedff0 100644 --- a/tools/play-requirements.txt +++ b/tools/play-requirements.txt @@ -4,7 +4,7 @@ charset-normalizer==3.5.2 cryptography==50.0.2 google-auth==2.61.0 idna==3.20 -pyasn1==0.6.4 +pyasn1==0.6.5 pyasn1_modules==0.4.2 pycparser==3.11 requests==2.34.2