phantasm_read 0.0.3 copy "phantasm_read: ^0.0.3" to clipboard
phantasm_read: ^0.0.3 copied to clipboard

Cross-platform Flutter comic and novel reader package.

example/lib/main.dart

import 'dart:convert';
import 'dart:typed_data';

import 'package:material_ui/material_ui.dart';
import 'package:phantasm_read/phantasm_read.dart';

import 'example_options.dart';
import 'example_settings_sheet.dart';
import 'trial_feedback.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const PhantasmReadExampleApp());
}

class PhantasmReadExampleApp extends StatelessWidget {
  const PhantasmReadExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'phantasm_read example',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF1B4D3E)),
        useMaterial3: true,
      ),
      builder: (context, child) {
        // Bridge Theme/Localizations for packages still on flutter/material
        // (e.g. extended_image) until the ecosystem migrates.
        // ignore: deprecated_member_use
        return MaterialUiCompatibilityBridge(child: child!);
      },
      home: const _HomePage(),
    );
  }
}

class _HomePage extends StatefulWidget {
  const _HomePage();

  @override
  State<_HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<_HomePage> {
  final ExampleReaderOptions _options = ExampleReaderOptions();
  @override
  void dispose() {
    _options.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _options,
      builder: (context, _) {
        return Scaffold(
          appBar: AppBar(title: const Text('phantasm_read')),
          body: ListView(
            children: [
              const ListTile(
                title: Text('0.0.3 演示'),
                subtitle: Text('常用功能默认开启 · 扩展能力见下方设置'),
              ),
              ListTile(
                leading: const Icon(Icons.photo_library_outlined),
                title: const Text('漫画阅读器'),
                subtitle: Text(_comicSubtitle),
                onTap: () {
                  Navigator.of(context).push(
                    MaterialPageRoute<void>(
                      builder: (_) => _ComicDemoPage(options: _options),
                    ),
                  );
                },
              ),
              ListTile(
                leading: const Icon(Icons.menu_book_outlined),
                title: const Text('小说阅读器(文本)'),
                subtitle: Text(_novelSubtitle),
                onTap: () {
                  Navigator.of(context).push(
                    MaterialPageRoute<void>(
                      builder: (_) => _TextNovelDemoPage(options: _options),
                    ),
                  );
                },
              ),
              ListTile(
                leading: const Icon(Icons.picture_as_pdf_outlined),
                title: const Text('PDF 阅读器'),
                subtitle: Text(_pdfSubtitle),
                onTap: () {
                  Navigator.of(context).push(
                    MaterialPageRoute<void>(
                      builder: (_) => _PdfDemoPage(options: _options),
                    ),
                  );
                },
              ),
              const Divider(height: 32),
              ListTile(
                leading: const Icon(Icons.tune),
                title: const Text('示例设置'),
                subtitle: const Text('水印 / 手绘 / 高亮 / 同步 / 试读 / RTL 等'),
                trailing: const Icon(Icons.chevron_right),
                onTap: () => showExampleSettingsSheet(context, _options),
              ),
              if (_options.comicInk ||
                  _options.novelInk ||
                  _options.pdfInk ||
                  _options.novelHighlight ||
                  _options.comicSync ||
                  _options.novelSync ||
                  _options.pdfSync)
                Padding(
                  padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
                  child: Text(
                    '已启用:${_enabledLabels.join(' · ')}',
                    style: Theme.of(context).textTheme.bodySmall,
                  ),
                ),
            ],
          ),
        );
      },
    );
  }

  String get _comicSubtitle {
    final tags = <String>['翻页', '缩放', '书签'];
    if (_options.comicInk) tags.add('手绘');
    if (_options.comicWatermark) tags.add('水印');
    if (_options.comicTrial) tags.add('试读${_options.comicTrialPages}页');
    return tags.join(' · ');
  }

  String get _novelSubtitle {
    final tags = <String>['排版', '目录', 'TTS'];
    if (_options.novelHighlight) tags.add('高亮');
    if (_options.novelInk) tags.add('手绘');
    if (_options.novelWatermark) tags.add('水印');
    if (_options.novelTrial) tags.add('试读${_options.novelTrialChapters}章');
    return tags.join(' · ');
  }

  String get _pdfSubtitle {
    final tags = <String>['在线 PDF', '工具栏'];
    if (_options.pdfWatermark) tags.add('水印');
    if (_options.pdfInk) tags.add('手绘');
    if (_options.pdfSync) tags.add('同步');
    if (_options.pdfRtl) tags.add('RTL');
    if (_options.pdfDoublePage) tags.add('双页');
    if (_options.pdfTrial) tags.add('试读${_options.pdfTrialPages}页');
    return tags.join(' · ');
  }

  List<String> get _enabledLabels {
    final out = <String>[];
    if (_options.comicInk) out.add('漫画手绘');
    if (_options.novelInk) out.add('小说手绘');
    if (_options.novelHighlight) out.add('段落高亮');
    if (_options.comicSync) out.add('漫画同步');
    if (_options.novelSync) out.add('小说同步');
    if (_options.pdfSync) out.add('PDF同步');
    if (_options.pdfInk) out.add('PDF手绘');
    return out;
  }
}

void _showSyncSnack(BuildContext context, ReaderSyncPayload payload) {
  final n = payload.bookmarks.length;
  final page = payload.progress?.pageIndex;
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(
      content: Text(
        'onSync · ${payload.bookId}'
        '${page != null ? ' · page $page' : ''}'
        '${n > 0 ? ' · bookmarks $n' : ''}',
      ),
      duration: const Duration(seconds: 2),
    ),
  );
}

Future<void> _showExportDialog(BuildContext context, String bookId) async {
  final data = await ReaderBookmarkStore.instance.exportJson(bookId);
  if (!context.mounted) return;
  final pretty = const JsonEncoder.withIndent('  ').convert(data);
  await showDialog<void>(
    context: context,
    builder: (ctx) => AlertDialog(
      title: Text('exportJson · $bookId'),
      content: SizedBox(
        width: 360,
        child: SingleChildScrollView(child: SelectableText(pretty)),
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(ctx),
          child: const Text('关闭'),
        ),
      ],
    ),
  );
}

class _ComicDemoPage extends StatefulWidget {
  const _ComicDemoPage({required this.options});

  final ExampleReaderOptions options;

  @override
  State<_ComicDemoPage> createState() => _ComicDemoPageState();
}

class _ComicDemoPageState extends State<_ComicDemoPage> {
  static const _bookId = 'demo_comic';

  ReaderSettings _settings = const ReaderSettings(
    brightness: 0.85,
    keepScreenOn: true,
  );
  @override
  void initState() {
    super.initState();
    _settings = _settings.copyWith(doublePage: widget.options.comicDoublePage);
    widget.options.addListener(_onOptionsChanged);
  }
  @override
  void dispose() {
    widget.options.removeListener(_onOptionsChanged);
    super.dispose();
  }
  void _onOptionsChanged() {
    setState(() {
      _settings = _settings.copyWith(doublePage: widget.options.comicDoublePage);
    });
  }
  @override
  Widget build(BuildContext context) {
    final o = widget.options;
    return AnimatedBuilder(
      animation: o,
      builder: (context, _) {
        return Scaffold(
          appBar: AppBar(
            title: const Text('Comic'),
            actions: [
              IconButton(
                tooltip: '示例设置',
                icon: const Icon(Icons.tune),
                onPressed: () => showExampleSettingsSheet(context, o),
              ),
              IconButton(
                tooltip: '导出书签 JSON',
                icon: const Icon(Icons.ios_share_outlined),
                onPressed: () => _showExportDialog(context, _bookId),
              ),
            ],
          ),
          body: ComicReader(
            bookId: _bookId,
            pages: ComicPages.fromUrls(const [
              'https://picsum.photos/seed/phantasm1/800/1200',
              'https://picsum.photos/seed/phantasm2/800/1200',
              'https://picsum.photos/seed/phantasm3/800/1200',
              'https://picsum.photos/seed/phantasm4/800/1200',
              'https://picsum.photos/seed/phantasm5/800/1200',
            ]),
            readingMode: ComicReadingMode.vertical,
            settings: _settings,
            rtl: o.comicRtl,
            persistSettings: false,
            trialLimit: o.comicTrialLimit,
            onTrialLimitReached: (event) {
              if (!context.mounted) return;
              handleTrialLimit(context, o.trialFeedback, event);
            },
            watermarkText: o.comicWatermarkText,
            enableInk: o.comicInk,
            pageTurnEffect: o.comicPageTurn,
            onSettingsChanged: (s) => setState(() => _settings = s),
            onSync: o.comicSync
                ? (payload) async {
                    if (!mounted) return;
                    _showSyncSnack(context, payload);
                  }
                : null,
          ),
        );
      },
    );
  }
}

String _buildSampleNovelText() {
  final buf = StringBuffer();
  const chapterTitles = [
    '启程',
    '林间',
    '溪畔',
    '山道',
    '驿站',
    '雨夜',
    '旧城',
    '集市',
    '塔楼',
    '渡口',
    '海风',
    '归途',
  ];
  for (var c = 0; c < chapterTitles.length; c++) {
    final n = c + 1;
    buf.writeln('第$n章 ${chapterTitles[c]}');
    buf.writeln();
    for (var p = 1; p <= 6; p++) {
      buf.writeln(
        '旅人沿着尘土飞扬的小路前行,靴底与碎石摩擦出细碎的声响。'
        '这是第$n章的第$p段:远处山峦在晨雾里起伏,像一页尚未写完的书。'
        '他想起昨夜篝火旁读过的句子,想起渡口老人说的那句「路还很长」。'
        '风从松林间穿过,带来潮湿的土腥与野花气息;他放慢脚步,'
        '把卷起的书页按平,继续向城门方向走去。',
      );
      buf.writeln();
      buf.writeln(
        '阳光渐渐升高,石板路上的影子缩短。偶尔有马车从身后驶过,'
        '扬起一阵灰,又很快落下。旅人抬头望向天空,云絮缓慢漂移,'
        '仿佛也在翻阅某种看不见的故事。他摸了摸行囊,确认书还在,'
        '便又低下头,把注意力放回脚下的每一步。',
      );
      buf.writeln();
    }
  }
  buf.writeln(
    '(示例文本共 ${chapterTitles.length} 章,可在工具栏切换竖读/横读测试翻页;'
    '首页「示例设置」可开启水印、手绘、高亮、同步等扩展能力。)',
  );
  return buf.toString();
}

class _TextNovelDemoPage extends StatefulWidget {
  const _TextNovelDemoPage({required this.options});

  final ExampleReaderOptions options;

  @override
  State<_TextNovelDemoPage> createState() => _TextNovelDemoPageState();
}

class _TextNovelDemoPageState extends State<_TextNovelDemoPage> {
  static const _bookId = 'demo_text_novel';

  ReaderSettings _settings = const ReaderSettings(
    brightness: 0.9,
    keepScreenOn: true,
    typography: NovelTypography(fontSize: 18, lineHeight: 1.7),
    backgroundColor: 0xFFFFF8E7,
    foregroundColor: 0xFF222222,
  );

  late final Uint8List _sampleBytes = Uint8List.fromList(
    utf8.encode(_buildSampleNovelText()),
  );

  @override
  Widget build(BuildContext context) {
    final o = widget.options;
    return AnimatedBuilder(
      animation: o,
      builder: (context, _) {
        return Scaffold(
          appBar: AppBar(
            title: const Text('Novel (text)'),
            actions: [
              IconButton(
                tooltip: '示例设置',
                icon: const Icon(Icons.tune),
                onPressed: () => showExampleSettingsSheet(context, o),
              ),
              IconButton(
                tooltip: '导出书签 JSON',
                icon: const Icon(Icons.ios_share_outlined),
                onPressed: () => _showExportDialog(context, _bookId),
              ),
            ],
          ),
          body: NovelReader(
            bookId: _bookId,
            source: NovelSource.textBytes(_sampleBytes, name: 'phantasm_sample.txt'),
            settings: _settings,
            persistSettings: false,
            trialLimit: o.novelTrialLimit,
            onTrialLimitReached: (event) {
              if (!context.mounted) return;
              handleTrialLimit(context, o.trialFeedback, event);
            },
            watermarkText: o.novelWatermarkText,
            enableInk: o.novelInk,
            enableHighlights: o.novelHighlight,
            rtl: o.novelRtl,
            onSettingsChanged: (s) => setState(() => _settings = s),
            onSync: o.novelSync
                ? (payload) async {
                    if (!mounted) return;
                    _showSyncSnack(context, payload);
                  }
                : null,
          ),
        );
      },
    );
  }
}

/// Public multi-page sample used by the PDF demo (Mozilla pdf.js).
const _kDemoPdfUrl =
    'https://mozilla.github.io/pdf.js/web/compressed.tracemonkey-pldi-09.pdf';

class _PdfDemoPage extends StatefulWidget {
  const _PdfDemoPage({required this.options});

  final ExampleReaderOptions options;

  @override
  State<_PdfDemoPage> createState() => _PdfDemoPageState();
}

class _PdfDemoPageState extends State<_PdfDemoPage> {
  ReaderSettings _settings = const ReaderSettings(
    brightness: 0.9,
    keepScreenOn: true,
    immersive: true,
  );

  @override
  void initState() {
    super.initState();
    _settings = _settings.copyWith(doublePage: widget.options.pdfDoublePage);
    widget.options.addListener(_onOptionsChanged);
  }

  @override
  void dispose() {
    widget.options.removeListener(_onOptionsChanged);
    super.dispose();
  }

  void _onOptionsChanged() {
    setState(() {
      _settings = _settings.copyWith(doublePage: widget.options.pdfDoublePage);
    });
  }

  @override
  Widget build(BuildContext context) {
    final options = widget.options;
    return AnimatedBuilder(
      animation: options,
      builder: (context, _) {
        return Scaffold(
          // Immersive reading: hide demo AppBar so the PDF can go edge-to-edge.
          appBar: _settings.immersive
              ? null
              : AppBar(
                  title: const Text('PDF'),
                  actions: [
                    IconButton(
                      tooltip: '示例设置',
                      icon: const Icon(Icons.tune),
                      onPressed: () =>
                          showExampleSettingsSheet(context, options),
                    ),
                  ],
                ),
          body: PdfReader(
            bookId: 'demo_pdf',
            source: PdfSource.url(_kDemoPdfUrl),
            readingMode: ComicReadingMode.horizontal,
            rtl: options.pdfRtl,
            trialLimit: options.pdfTrialLimit,
            onTrialLimitReached: (event) {
              if (!context.mounted) return;
              handleTrialLimit(context, options.trialFeedback, event);
            },
            watermarkText: options.pdfWatermarkText,
            enableInk: options.pdfInk,
            rasterDpi: options.pdfRasterDpi,
            settings: _settings,
            onSettingsChanged: (s) => setState(() => _settings = s),
            onSync: options.pdfSync
                ? (payload) async {
                    if (!mounted) return;
                    _showSyncSnack(context, payload);
                  }
                : null,
          ),
        );
      },
    );
  }
}
0
likes
160
points
236
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Cross-platform Flutter comic and novel reader package.

Homepage

License

MIT (license)

Dependencies

archive, audioplayers, charset_converter, extended_image, flutter, http, material_ui, path_provider, pdf, printing, screen_brightness, shared_preferences, wakelock_plus, webview_flutter

More

Packages that depend on phantasm_read