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/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..926b726d 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, 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/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/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/preferences_provider.dart b/app/lib/providers/preferences_provider.dart index 2fad0e64..b8ca7b95 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(); } + /// Page content alignment. Currently applied only by Statistics. + 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/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/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/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/profile/appearance_settings_section.dart b/app/lib/widgets/profile/appearance_settings_section.dart index ce0c10ce..52a3db35 100644 --- a/app/lib/widgets/profile/appearance_settings_section.dart +++ b/app/lib/widgets/profile/appearance_settings_section.dart @@ -43,6 +43,7 @@ class AppearanceSettingsSection extends StatelessWidget { children: [ const SettingsSectionHeader(title: 'Appearance'), SettingsRow(label: 'Theme', value: getThemeLabel(prefs.themeModePref), onTap: () => _showThemePicker(context)), + _buildPageAlignment(context), ], ); } @@ -52,10 +53,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/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/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/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/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/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..3bff2e43 --- /dev/null +++ b/app/test/reader/reading_activity_integration_test.dart @@ -0,0 +1,131 @@ +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('actual reader coverage credits consecutive EPUB pages without collapsing a chapter', (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; + final firstTotal = (firstPages.end - firstPages.start) * firstPages.pagesPerUnit; + 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)); + + if (first.chapterCount != null) { + // The Git-pinned reader emits normalized chapter extents. + expect(coverage.map((extent) => (extent.start, extent.end)), [ + (first.start, first.end), + (second.start, second.end), + ]); + expect(coverage.every((extent) => extent.estimated), isTrue); + final total = coverage.fold(0, (sum, extent) => sum + (extent.end - extent.start) * extent.pagesPerUnit); + expect(total, greaterThan(firstTotal)); + } else { + expect(coverage.map((extent) => extent.key).toSet(), hasLength(2)); + expect(coverage.every((extent) => !extent.estimated && extent.end - extent.start == 1), isTrue); + } + }); +} + +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/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/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); + } + }); +}