├── .github └── workflows │ └── prerelease.yaml ├── .gitignore ├── .metadata ├── LICENSE ├── Makefile ├── README.md ├── analysis_options.yaml ├── android ├── .gitignore ├── app │ ├── build.gradle │ └── src │ │ ├── debug │ │ └── AndroidManifest.xml │ │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── kotlin │ │ │ └── com │ │ │ │ └── example │ │ │ │ └── masiro │ │ │ │ └── MainActivity.kt │ │ └── res │ │ │ ├── drawable-hdpi │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── drawable-mdpi │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── drawable-v21 │ │ │ └── launch_background.xml │ │ │ ├── drawable-xhdpi │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── drawable-xxhdpi │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── drawable-xxxhdpi │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── drawable │ │ │ └── launch_background.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ └── launcher_icon.xml │ │ │ ├── mipmap-hdpi │ │ │ ├── ic_launcher.png │ │ │ └── launcher_icon.png │ │ │ ├── mipmap-mdpi │ │ │ ├── ic_launcher.png │ │ │ └── launcher_icon.png │ │ │ ├── mipmap-xhdpi │ │ │ ├── ic_launcher.png │ │ │ └── launcher_icon.png │ │ │ ├── mipmap-xxhdpi │ │ │ ├── ic_launcher.png │ │ │ └── launcher_icon.png │ │ │ ├── mipmap-xxxhdpi │ │ │ ├── ic_launcher.png │ │ │ └── launcher_icon.png │ │ │ ├── values-night │ │ │ └── styles.xml │ │ │ └── values │ │ │ ├── colors.xml │ │ │ └── styles.xml │ │ └── profile │ │ └── AndroidManifest.xml ├── build.gradle ├── gradle.properties ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties └── settings.gradle ├── assets └── icon │ └── icon.png ├── devtools_options.yaml ├── flutter_launcher_icons.yaml ├── l10n.yaml ├── lib ├── bloc │ ├── global │ │ ├── app_theme_cubit.dart │ │ └── user │ │ │ ├── user_bloc.dart │ │ │ ├── user_event.dart │ │ │ └── user_state.dart │ ├── screen │ │ ├── comments │ │ │ ├── comments_screen_bloc.dart │ │ │ ├── comments_screen_event.dart │ │ │ └── comments_screen_state.dart │ │ ├── favorites │ │ │ ├── favorites_screen_bloc.dart │ │ │ ├── favorites_screen_event.dart │ │ │ └── favorites_screen_state.dart │ │ ├── novel │ │ │ ├── novel_screen_bloc.dart │ │ │ ├── novel_screen_event.dart │ │ │ └── novel_screen_state.dart │ │ ├── reader │ │ │ ├── reader_screen_bloc.dart │ │ │ ├── reader_screen_event.dart │ │ │ └── reader_screen_state.dart │ │ ├── search │ │ │ ├── search_screen_bloc.dart │ │ │ ├── search_screen_event.dart │ │ │ └── search_screen_state.dart │ │ └── settings │ │ │ ├── settings_screen_bloc.dart │ │ │ ├── settings_screen_event.dart │ │ │ └── settings_screen_state.dart │ └── util │ │ └── event_transformer.dart ├── data │ ├── database │ │ ├── core.dart │ │ ├── dao │ │ │ ├── app_configuration_dao.dart │ │ │ ├── chapter_record_dao.dart │ │ │ └── user_dao.dart │ │ ├── entity │ │ │ ├── app_configuration_entity.dart │ │ │ ├── app_configuration_entity.g.dart │ │ │ ├── chapter_record_entity.dart │ │ │ ├── chapter_record_entity.g.dart │ │ │ ├── current_cookie_entity.dart │ │ │ ├── current_cookie_entity.g.dart │ │ │ ├── novel_record_entity.dart │ │ │ ├── novel_record_entity.g.dart │ │ │ ├── user_entity.dart │ │ │ └── user_entity.g.dart │ │ └── migration │ │ │ ├── migration.dart │ │ │ └── migration_1_to_2.dart │ ├── network │ │ ├── masiro_api.dart │ │ └── response │ │ │ ├── chapter_detail_response.dart │ │ │ ├── common_response.dart │ │ │ ├── novel_detail_response.dart │ │ │ ├── paged_comment_response.dart │ │ │ ├── paged_novel_response.dart │ │ │ ├── profile_response.dart │ │ │ └── volume_response.dart │ └── repository │ │ ├── adapter │ │ ├── app_configuration_entity_adapter.dart │ │ ├── chapter_detail_response_adapter.dart │ │ ├── chapter_record_entity_adapter.dart │ │ ├── comment_response_adapter.dart │ │ ├── novel_detail_response_adapter.dart │ │ ├── novel_response_adapter.dart │ │ ├── profile_response_adapter.dart │ │ ├── user_entity_adapter.dart │ │ └── volume_response_adapter.dart │ │ ├── app_configuration_repository.dart │ │ ├── favorites_repository.dart │ │ ├── masiro_repository.dart │ │ ├── model │ │ ├── app_configuration.dart │ │ ├── chapter_detail.dart │ │ ├── chapter_record.dart │ │ ├── comment.dart │ │ ├── loading_status.dart │ │ ├── novel.dart │ │ ├── novel_detail.dart │ │ ├── paged_data.dart │ │ ├── profile.dart │ │ ├── read_position.dart │ │ ├── reading_mode.dart │ │ ├── user.dart │ │ └── volume.dart │ │ ├── novel_record_repository.dart │ │ ├── preferences_repository.dart │ │ ├── profile_repository.dart │ │ └── user_repository.dart ├── di │ ├── get_it.dart │ ├── injectable.config.dart │ └── injectable.dart ├── l10n │ ├── app_zh.arb │ ├── app_zh_hant_hk.arb │ └── app_zh_hant_tw.arb ├── main.dart ├── misc │ ├── chapter.dart │ ├── chrome.dart │ ├── constant.dart │ ├── context.dart │ ├── cookie.dart │ ├── cookie_storage.dart │ ├── easy_refresh.dart │ ├── helper.dart │ ├── list.dart │ ├── oss_licenses.dart │ ├── platform.dart │ ├── pubspec.dart │ ├── render.dart │ ├── router.dart │ ├── time.dart │ ├── toast.dart │ ├── tray_icon.dart │ └── url.dart └── ui │ ├── app.dart │ ├── screens │ ├── about │ │ └── about_screen.dart │ ├── comments │ │ ├── comment_card.dart │ │ ├── comment_card_header.dart │ │ ├── comment_card_reply_list.dart │ │ ├── comments_screen.dart │ │ └── pagination_dialog.dart │ ├── error │ │ └── error_screen.dart │ ├── favorites │ │ └── favorites_screen.dart │ ├── home │ │ └── home_screen.dart │ ├── license │ │ └── license_screen.dart │ ├── licenses │ │ └── licenses_screen.dart │ ├── login │ │ ├── desktop_login.dart │ │ ├── login_screen.dart │ │ └── mobile_phone_login.dart │ ├── novel │ │ ├── expandable_brief.dart │ │ ├── novel_header.dart │ │ ├── novel_screen.dart │ │ └── volume_list.dart │ ├── reader │ │ ├── bottom_bar.dart │ │ ├── chapter_content_scroll.dart │ │ ├── payment_detail.dart │ │ ├── pointer_area_indicator.dart │ │ ├── reader_screen.dart │ │ ├── settings_sheet.dart │ │ └── top_bar.dart │ ├── search │ │ ├── novel_list.dart │ │ ├── search_screen.dart │ │ └── search_top_bar.dart │ └── settings │ │ ├── about_card.dart │ │ ├── accounts_card.dart │ │ ├── license_card.dart │ │ ├── logout_card.dart │ │ ├── profile_card.dart │ │ ├── settings_screen.dart │ │ ├── sign_in_card.dart │ │ ├── theme_color_card.dart │ │ └── theme_mode_card.dart │ └── widgets │ ├── adaptive_status_bar_style.dart │ ├── after_layout.dart │ ├── cached_image.dart │ ├── error_message.dart │ ├── manual_tooltip.dart │ ├── message.dart │ ├── nav_bar.dart │ ├── novel_card.dart │ └── router_outlet_with_nav_bar.dart ├── linux ├── .gitignore ├── CMakeLists.txt ├── flutter │ ├── CMakeLists.txt │ ├── generated_plugin_registrant.cc │ ├── generated_plugin_registrant.h │ └── generated_plugins.cmake ├── main.cc ├── my_application.cc └── my_application.h ├── macos ├── .gitignore ├── Flutter │ ├── Flutter-Debug.xcconfig │ ├── Flutter-Release.xcconfig │ └── GeneratedPluginRegistrant.swift ├── Runner.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── xcschemes │ │ └── Runner.xcscheme ├── Runner.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── Runner │ ├── AppDelegate.swift │ ├── Assets.xcassets │ │ └── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ ├── app_icon_1024.png │ │ │ ├── app_icon_128.png │ │ │ ├── app_icon_16.png │ │ │ ├── app_icon_256.png │ │ │ ├── app_icon_32.png │ │ │ ├── app_icon_512.png │ │ │ └── app_icon_64.png │ ├── Base.lproj │ │ └── MainMenu.xib │ ├── Configs │ │ ├── AppInfo.xcconfig │ │ ├── Debug.xcconfig │ │ ├── Release.xcconfig │ │ └── Warnings.xcconfig │ ├── DebugProfile.entitlements │ ├── Info.plist │ ├── MainFlutterWindow.swift │ └── Release.entitlements └── RunnerTests │ └── RunnerTests.swift ├── pubspec.lock ├── pubspec.yaml ├── test └── widget_test.dart └── windows ├── .gitignore ├── CMakeLists.txt ├── flutter ├── CMakeLists.txt ├── generated_plugin_registrant.cc ├── generated_plugin_registrant.h └── generated_plugins.cmake └── runner ├── CMakeLists.txt ├── Runner.rc ├── flutter_window.cpp ├── flutter_window.h ├── main.cpp ├── resource.h ├── resources └── app_icon.ico ├── runner.exe.manifest ├── utils.cpp ├── utils.h ├── win32_window.cpp └── win32_window.h /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | migrate_working_dir/ 12 | 13 | # IntelliJ related 14 | *.iml 15 | *.ipr 16 | *.iws 17 | .idea/ 18 | 19 | # The .vscode folder contains launch configuration and tasks you configure in 20 | # VS Code which you may wish to be included in version control, so this line 21 | # is commented out by default. 22 | #.vscode/ 23 | 24 | # Flutter/Dart/Pub related 25 | **/doc/api/ 26 | **/ios/Flutter/.last_build_id 27 | .dart_tool/ 28 | .flutter-plugins 29 | .flutter-plugins-dependencies 30 | .pub-cache/ 31 | .pub/ 32 | /build/ 33 | 34 | # Symbolication related 35 | app.*.symbols 36 | 37 | # Obfuscation related 38 | app.*.map.json 39 | 40 | # Android Studio will place build artifacts here 41 | /android/app/debug 42 | /android/app/profile 43 | /android/app/release 44 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: "5874a72aa4c779a02553007c47dacbefba2374dc" 8 | channel: "stable" 9 | 10 | project_type: app 11 | 12 | # Tracks metadata for the flutter migrate command 13 | migration: 14 | platforms: 15 | - platform: root 16 | create_revision: 5874a72aa4c779a02553007c47dacbefba2374dc 17 | base_revision: 5874a72aa4c779a02553007c47dacbefba2374dc 18 | - platform: android 19 | create_revision: 5874a72aa4c779a02553007c47dacbefba2374dc 20 | base_revision: 5874a72aa4c779a02553007c47dacbefba2374dc 21 | - platform: linux 22 | create_revision: 5874a72aa4c779a02553007c47dacbefba2374dc 23 | base_revision: 5874a72aa4c779a02553007c47dacbefba2374dc 24 | - platform: macos 25 | create_revision: 5874a72aa4c779a02553007c47dacbefba2374dc 26 | base_revision: 5874a72aa4c779a02553007c47dacbefba2374dc 27 | - platform: windows 28 | create_revision: 5874a72aa4c779a02553007c47dacbefba2374dc 29 | base_revision: 5874a72aa4c779a02553007c47dacbefba2374dc 30 | 31 | # User provided section 32 | 33 | # List of Local paths (relative to this file) that should be 34 | # ignored by the migrate tool. 35 | # 36 | # Files that are not part of the templates will be ignored by default. 37 | unmanaged_files: 38 | - 'lib/main.dart' 39 | - 'ios/Runner.xcodeproj/project.pbxproj' 40 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: watch 2 | watch: 3 | dart run build_runner watch --delete-conflicting-outputs 4 | 5 | .PHONY: fix 6 | fix: 7 | dart fix --apply 8 | 9 | .PHONY: format 10 | format: fix 11 | dart format . 12 | 13 | .PHONY: gen-l10n 14 | gen-l10n: 15 | flutter gen-l10n 16 | 17 | .PHONY: launcher-icons 18 | launcher-icons: 19 | dart run flutter_launcher_icons 20 | 21 | .PHONY: licenses 22 | licenses: 23 | flutter pub run flutter_oss_licenses:generate.dart -o lib/misc/oss_licenses.dart 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | icon 3 |

Masiro

4 |
5 | 6 | 一款使用 Flutter 开发的第三方真白萌客户端。 7 | 8 | ## 注意 9 | 10 | - 这是一个用于学习 Flutter 的练手项目,限于作者知识水平,在使用过程中可能会出现一些问题,欢迎提 issue 和 PR 11 | - 该项目现在还处于开发的早期阶段,特性尚未稳定,pre-release 版本在使用时产生的用户数据可能不会被继承到正式版 12 | 13 | ## 介绍 14 | 15 | 该项目现在还处于开发的早期阶段,功能尚未完善,目前支持的平台如下: 16 | 17 | - [x] Linux:支持 18 | - [ ] Windows:未测试 19 | - [ ] macOS:未测试 20 | - [x] Android: 支持 21 | - [ ] iOS: 暂无计划 22 | 23 | ## 致谢 24 | 25 | 特别感谢 [Qing-Novel](https://github.com/Qing-Novel) 大佬制作的图标。 26 | 27 | ## License 28 | 29 | GPL-3.0 license. 30 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the analyzer, which statically analyzes Dart code to 2 | # check for errors, warnings, and lints. 3 | # 4 | # The issues identified by the analyzer are surfaced in the UI of Dart-enabled 5 | # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be 6 | # invoked from the command line by running `flutter analyze`. 7 | 8 | # The following line activates a set of recommended lints for Flutter apps, 9 | # packages, and plugins designed to encourage good coding practices. 10 | include: package:flutter_lints/flutter.yaml 11 | 12 | linter: 13 | # The lint rules applied to this project can be customized in the 14 | # section below to disable rules from the `package:flutter_lints/flutter.yaml` 15 | # included above or to enable additional rules. A list of all available lints 16 | # and their documentation is published at https://dart.dev/lints. 17 | # 18 | # Instead of disabling a lint rule for the entire project in the 19 | # section below, it can also be suppressed for a single line of code 20 | # or a specific dart file by using the `// ignore: name_of_lint` and 21 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file 22 | # producing the lint. 23 | rules: 24 | always_declare_return_types: true 25 | avoid_print: true 26 | prefer_single_quotes: true 27 | always_use_package_imports: true 28 | avoid_empty_else: true 29 | comment_references: true 30 | constant_identifier_names: true 31 | curly_braces_in_flow_control_structures: true 32 | directives_ordering: true 33 | flutter_style_todos: true 34 | package_names: true 35 | prefer_contains: true 36 | prefer_if_null_operators: true 37 | prefer_is_empty: true 38 | prefer_is_not_operator: true 39 | prefer_iterable_whereType: true 40 | prefer_mixin: true 41 | prefer_null_aware_method_calls: true 42 | prefer_null_aware_operators: true 43 | prefer_spread_collections: true 44 | require_trailing_commas: true 45 | sized_box_shrink_expand: true 46 | unawaited_futures: true 47 | unnecessary_await_in_return: true 48 | unnecessary_breaks: true 49 | unnecessary_const: true 50 | prefer_final_locals: true 51 | prefer_final_in_for_each: true 52 | prefer_final_fields: true 53 | unnecessary_late: true 54 | unnecessary_new: true 55 | unnecessary_parenthesis: true 56 | unnecessary_statements: true 57 | unnecessary_string_escapes: true 58 | unnecessary_to_list_in_spreads: true 59 | use_enums: true 60 | use_if_null_to_convert_nulls_to_bools: true 61 | use_named_constants: true 62 | use_raw_strings: true 63 | use_to_and_as_if_applicable: true 64 | -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | gradle-wrapper.jar 2 | /.gradle 3 | /captures/ 4 | /gradlew 5 | /gradlew.bat 6 | /local.properties 7 | GeneratedPluginRegistrant.java 8 | 9 | # Remember to never publicly share your keystore. 10 | # See https://flutter.dev/to/reference-keystore 11 | key.properties 12 | **/*.keystore 13 | **/*.jks 14 | -------------------------------------------------------------------------------- /android/app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "com.android.application" 3 | id "kotlin-android" 4 | // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. 5 | id "dev.flutter.flutter-gradle-plugin" 6 | } 7 | 8 | android { 9 | namespace = "com.example.masiro" 10 | compileSdk = flutter.compileSdkVersion 11 | ndkVersion = flutter.ndkVersion 12 | 13 | compileOptions { 14 | sourceCompatibility = JavaVersion.VERSION_1_8 15 | targetCompatibility = JavaVersion.VERSION_1_8 16 | } 17 | 18 | kotlinOptions { 19 | jvmTarget = JavaVersion.VERSION_1_8 20 | } 21 | 22 | defaultConfig { 23 | // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). 24 | applicationId = "com.example.masiro" 25 | // You can update the following values to match your application needs. 26 | // For more information, see: https://flutter.dev/to/review-gradle-config. 27 | minSdk = flutter.minSdkVersion 28 | targetSdk = flutter.targetSdkVersion 29 | versionCode = flutter.versionCode 30 | versionName = flutter.versionName 31 | minSdkVersion flutter.minSdkVersion 32 | } 33 | 34 | buildTypes { 35 | release { 36 | // TODO: Add your own signing config for the release build. 37 | // Signing with the debug keys for now, so `flutter run --release` works. 38 | signingConfig = signingConfigs.debug 39 | } 40 | } 41 | } 42 | 43 | flutter { 44 | source = "../.." 45 | } 46 | -------------------------------------------------------------------------------- /android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 17 | 21 | 25 | 26 | 27 | 28 | 29 | 30 | 32 | 35 | 36 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /android/app/src/main/kotlin/com/example/masiro/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.example.masiro 2 | 3 | import io.flutter.embedding.android.FlutterActivity 4 | 5 | class MainActivity: FlutterActivity() 6 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qixiaoo/masiro/eff339269371e50d432663d4a55402ba3d8d582c/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qixiaoo/masiro/eff339269371e50d432663d4a55402ba3d8d582c/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qixiaoo/masiro/eff339269371e50d432663d4a55402ba3d8d582c/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qixiaoo/masiro/eff339269371e50d432663d4a55402ba3d8d582c/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-v21/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qixiaoo/masiro/eff339269371e50d432663d4a55402ba3d8d582c/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qixiaoo/masiro/eff339269371e50d432663d4a55402ba3d8d582c/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qixiaoo/masiro/eff339269371e50d432663d4a55402ba3d8d582c/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qixiaoo/masiro/eff339269371e50d432663d4a55402ba3d8d582c/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qixiaoo/masiro/eff339269371e50d432663d4a55402ba3d8d582c/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qixiaoo/masiro/eff339269371e50d432663d4a55402ba3d8d582c/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qixiaoo/masiro/eff339269371e50d432663d4a55402ba3d8d582c/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/launcher_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qixiaoo/masiro/eff339269371e50d432663d4a55402ba3d8d582c/android/app/src/main/res/mipmap-hdpi/launcher_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qixiaoo/masiro/eff339269371e50d432663d4a55402ba3d8d582c/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/launcher_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qixiaoo/masiro/eff339269371e50d432663d4a55402ba3d8d582c/android/app/src/main/res/mipmap-mdpi/launcher_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qixiaoo/masiro/eff339269371e50d432663d4a55402ba3d8d582c/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/launcher_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qixiaoo/masiro/eff339269371e50d432663d4a55402ba3d8d582c/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qixiaoo/masiro/eff339269371e50d432663d4a55402ba3d8d582c/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qixiaoo/masiro/eff339269371e50d432663d4a55402ba3d8d582c/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qixiaoo/masiro/eff339269371e50d432663d4a55402ba3d8d582c/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qixiaoo/masiro/eff339269371e50d432663d4a55402ba3d8d582c/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 16 | 20 | 21 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #547dd1 4 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 16 | 20 | 21 | -------------------------------------------------------------------------------- /android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | allprojects { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | } 6 | } 7 | 8 | rootProject.buildDir = "../build" 9 | subprojects { 10 | project.buildDir = "${rootProject.buildDir}/${project.name}" 11 | } 12 | subprojects { 13 | // fix for verifyReleaseResources, see: https://github.com/isar/isar/issues/1654 14 | // ============ 15 | afterEvaluate { project -> 16 | if (project.plugins.hasPlugin("com.android.application") || 17 | project.plugins.hasPlugin("com.android.library")) { 18 | project.android { 19 | compileSdkVersion 34 20 | buildToolsVersion "34.0.0" 21 | } 22 | } 23 | if (project.hasProperty("android")) { 24 | project.android { 25 | if (namespace == null) { 26 | namespace project.group 27 | } 28 | } 29 | } 30 | } 31 | // ============ 32 | 33 | project.evaluationDependsOn(":app") 34 | } 35 | 36 | tasks.register("clean", Delete) { 37 | delete rootProject.buildDir 38 | } 39 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | zipStoreBase=GRADLE_USER_HOME 4 | zipStorePath=wrapper/dists 5 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip 6 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | def flutterSdkPath = { 3 | def properties = new Properties() 4 | file("local.properties").withInputStream { properties.load(it) } 5 | def flutterSdkPath = properties.getProperty("flutter.sdk") 6 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties" 7 | return flutterSdkPath 8 | }() 9 | 10 | includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") 11 | 12 | repositories { 13 | google() 14 | mavenCentral() 15 | gradlePluginPortal() 16 | } 17 | } 18 | 19 | plugins { 20 | id "dev.flutter.flutter-plugin-loader" version "1.0.0" 21 | id "com.android.application" version "7.3.0" apply false 22 | id "org.jetbrains.kotlin.android" version "1.7.10" apply false 23 | } 24 | 25 | include ":app" 26 | -------------------------------------------------------------------------------- /assets/icon/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qixiaoo/masiro/eff339269371e50d432663d4a55402ba3d8d582c/assets/icon/icon.png -------------------------------------------------------------------------------- /devtools_options.yaml: -------------------------------------------------------------------------------- 1 | description: This file stores settings for Dart & Flutter DevTools. 2 | documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states 3 | extensions: 4 | -------------------------------------------------------------------------------- /flutter_launcher_icons.yaml: -------------------------------------------------------------------------------- 1 | # flutter pub run flutter_launcher_icons 2 | flutter_launcher_icons: 3 | image_path: "assets/icon/icon.png" 4 | 5 | android: "launcher_icon" 6 | # image_path_android: "assets/icon/icon.png" 7 | min_sdk_android: 21 # android min sdk min:16, default 21 8 | adaptive_icon_background: "#547dd1" 9 | adaptive_icon_foreground: "assets/icon/icon.png" 10 | adaptive_icon_monochrome: "assets/icon/icon.png" 11 | 12 | ios: false 13 | # image_path_ios: "assets/icon/icon.png" 14 | remove_alpha_channel_ios: true 15 | # image_path_ios_dark_transparent: "assets/icon/icon_dark.png" 16 | # image_path_ios_tinted_grayscale: "assets/icon/icon_tinted.png" 17 | # desaturate_tinted_to_grayscale_ios: true 18 | 19 | web: 20 | generate: false 21 | image_path: "assets/icon/icon.png" 22 | background_color: "#hexcode" 23 | theme_color: "#hexcode" 24 | 25 | windows: 26 | generate: false 27 | image_path: "assets/icon/icon.png" 28 | icon_size: 48 # min:48, max:256, default: 48 29 | 30 | macos: 31 | generate: false 32 | image_path: "assets/icon/icon.png" 33 | -------------------------------------------------------------------------------- /l10n.yaml: -------------------------------------------------------------------------------- 1 | arb-dir: lib/l10n 2 | template-arb-file: app_zh.arb 3 | output-localization-file: app_localizations.dart 4 | -------------------------------------------------------------------------------- /lib/bloc/global/app_theme_cubit.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_bloc/flutter_bloc.dart'; 4 | import 'package:masiro/data/repository/app_configuration_repository.dart'; 5 | import 'package:masiro/di/get_it.dart'; 6 | import 'package:masiro/misc/constant.dart'; 7 | 8 | class AppThemeCubit extends Cubit { 9 | final _appConfigurationRepository = getIt(); 10 | 11 | AppThemeCubit() : super(const AppThemeData()) { 12 | _initialize(); 13 | } 14 | 15 | Future _initialize() async { 16 | final config = await _appConfigurationRepository.getAppConfiguration(); 17 | emit( 18 | state.copyWith( 19 | themeMode: config.themeMode, 20 | themeColor: config.themeColor, 21 | ), 22 | ); 23 | } 24 | 25 | Future setThemeMode(ThemeMode themeMode) async { 26 | await _appConfigurationRepository.setThemeMode(themeMode); 27 | emit(state.copyWith(themeMode: themeMode)); 28 | } 29 | 30 | Future setThemeColor(int themeColor) async { 31 | await _appConfigurationRepository.setThemeColor(themeColor); 32 | emit(state.copyWith(themeColor: themeColor)); 33 | } 34 | } 35 | 36 | class AppThemeData extends Equatable { 37 | final ThemeMode themeMode; 38 | final int themeColor; 39 | 40 | const AppThemeData({ 41 | this.themeMode = ThemeMode.system, 42 | this.themeColor = defaultThemeColor, 43 | }); 44 | 45 | AppThemeData copyWith({ 46 | ThemeMode? themeMode, 47 | int? themeColor, 48 | }) { 49 | return AppThemeData( 50 | themeMode: themeMode ?? this.themeMode, 51 | themeColor: themeColor ?? this.themeColor, 52 | ); 53 | } 54 | 55 | @override 56 | List get props => [themeMode, themeColor]; 57 | } 58 | -------------------------------------------------------------------------------- /lib/bloc/global/user/user_event.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:masiro/data/repository/model/user.dart'; 3 | 4 | sealed class UserEvent extends Equatable { 5 | @override 6 | List get props => []; 7 | } 8 | 9 | final class UserInitialized extends UserEvent {} 10 | 11 | final class UserSignedIn extends UserEvent {} 12 | 13 | final class UserSwitched extends UserEvent { 14 | final User user; 15 | 16 | UserSwitched({required this.user}); 17 | 18 | @override 19 | List get props => [user]; 20 | } 21 | -------------------------------------------------------------------------------- /lib/bloc/global/user/user_state.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:masiro/data/repository/model/user.dart'; 3 | 4 | class UserState extends Equatable { 5 | final User? currentUser; 6 | final List userList; 7 | 8 | const UserState({ 9 | this.currentUser, 10 | this.userList = const [], 11 | }); 12 | 13 | UserState copyWith({ 14 | User? currentUser, 15 | List? userList, 16 | }) { 17 | return UserState( 18 | currentUser: currentUser ?? this.currentUser, 19 | userList: userList ?? this.userList, 20 | ); 21 | } 22 | 23 | @override 24 | List get props => [ 25 | currentUser, 26 | userList, 27 | ]; 28 | } 29 | -------------------------------------------------------------------------------- /lib/bloc/screen/comments/comments_screen_event.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:masiro/data/repository/model/comment.dart'; 3 | import 'package:masiro/data/repository/model/paged_data.dart'; 4 | 5 | sealed class CommentsScreenEvent extends Equatable { 6 | @override 7 | List get props => []; 8 | } 9 | 10 | final class CommentsScreenRefreshed extends CommentsScreenEvent { 11 | final PagedData pagedData; 12 | 13 | CommentsScreenRefreshed({required this.pagedData}); 14 | 15 | @override 16 | List get props => [pagedData]; 17 | } 18 | 19 | final class CommentsScreenPrevPageRequested extends CommentsScreenEvent { 20 | final PagedData pagedData; 21 | 22 | CommentsScreenPrevPageRequested({required this.pagedData}); 23 | 24 | @override 25 | List get props => [pagedData]; 26 | } 27 | 28 | final class CommentsScreenNextPageRequested extends CommentsScreenEvent { 29 | final PagedData pagedData; 30 | 31 | CommentsScreenNextPageRequested({required this.pagedData}); 32 | 33 | @override 34 | List get props => [pagedData]; 35 | } 36 | -------------------------------------------------------------------------------- /lib/bloc/screen/comments/comments_screen_state.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:masiro/data/repository/model/comment.dart'; 3 | import 'package:masiro/data/repository/model/paged_data.dart'; 4 | 5 | class CommentsScreenState extends Equatable { 6 | final List> pagedComments; 7 | 8 | final int page; 9 | 10 | final int totalCount; 11 | 12 | final int totalPages; 13 | 14 | const CommentsScreenState({ 15 | this.pagedComments = const [], 16 | this.page = 1, 17 | this.totalCount = 0, 18 | this.totalPages = 0, 19 | }); 20 | 21 | CommentsScreenState copyWith({ 22 | List>? pagedComments, 23 | int? page, 24 | int? totalCount, 25 | int? totalPages, 26 | }) { 27 | return CommentsScreenState( 28 | pagedComments: pagedComments ?? this.pagedComments, 29 | page: page ?? this.page, 30 | totalCount: totalCount ?? this.totalCount, 31 | totalPages: totalPages ?? this.totalPages, 32 | ); 33 | } 34 | 35 | List get comments { 36 | final list = []; 37 | for (final pagedComment in pagedComments) { 38 | list.addAll(pagedComment.data); 39 | } 40 | return list; 41 | } 42 | 43 | @override 44 | List get props => [ 45 | pagedComments, 46 | page, 47 | totalCount, 48 | totalPages, 49 | ]; 50 | } 51 | -------------------------------------------------------------------------------- /lib/bloc/screen/favorites/favorites_screen_bloc.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_bloc/flutter_bloc.dart'; 2 | import 'package:masiro/bloc/screen/favorites/favorites_screen_event.dart'; 3 | import 'package:masiro/bloc/screen/favorites/favorites_screen_state.dart'; 4 | import 'package:masiro/data/repository/favorites_repository.dart'; 5 | import 'package:masiro/di/get_it.dart'; 6 | 7 | typedef _FavoritesScreenBloc = Bloc; 8 | 9 | class FavoritesScreenBloc extends _FavoritesScreenBloc { 10 | final _favoritesRepository = getIt(); 11 | 12 | FavoritesScreenBloc() : super(FavoritesScreenInitialState()) { 13 | on(_onRequestFavoritesScreen); 14 | on(_onRefreshFavoritesScreen); 15 | } 16 | 17 | Future _onRequestFavoritesScreen( 18 | FavoritesScreenRequested event, 19 | Emitter emit, 20 | ) async { 21 | try { 22 | final favorites = await _favoritesRepository.getFavorites(); 23 | emit(FavoritesScreenLoadedState(novels: favorites)); 24 | } catch (e) { 25 | emit(FavoritesScreenErrorState(message: e.toString())); 26 | } 27 | } 28 | 29 | Future _onRefreshFavoritesScreen( 30 | FavoritesScreenRefreshed event, 31 | Emitter emit, 32 | ) async { 33 | try { 34 | final favorites = await _favoritesRepository.refreshFavorites(); 35 | emit(FavoritesScreenLoadedState(novels: favorites)); 36 | } catch (e) { 37 | emit(FavoritesScreenErrorState(message: e.toString())); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /lib/bloc/screen/favorites/favorites_screen_event.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | 3 | sealed class FavoritesScreenEvent extends Equatable { 4 | @override 5 | List get props => []; 6 | } 7 | 8 | final class FavoritesScreenRequested extends FavoritesScreenEvent {} 9 | 10 | final class FavoritesScreenRefreshed extends FavoritesScreenEvent {} 11 | -------------------------------------------------------------------------------- /lib/bloc/screen/favorites/favorites_screen_state.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:masiro/data/repository/model/novel.dart'; 3 | 4 | sealed class FavoritesScreenState extends Equatable { 5 | @override 6 | List get props => []; 7 | } 8 | 9 | class FavoritesScreenInitialState extends FavoritesScreenState {} 10 | 11 | class FavoritesScreenErrorState extends FavoritesScreenState { 12 | final String? message; 13 | 14 | FavoritesScreenErrorState({this.message}); 15 | 16 | @override 17 | List get props => [message]; 18 | } 19 | 20 | class FavoritesScreenLoadedState extends FavoritesScreenState { 21 | final List novels; 22 | 23 | FavoritesScreenLoadedState({this.novels = const []}); 24 | 25 | FavoritesScreenLoadedState copyWith({List? novels}) { 26 | return FavoritesScreenLoadedState(novels: novels ?? this.novels); 27 | } 28 | 29 | @override 30 | List get props => [novels]; 31 | } 32 | -------------------------------------------------------------------------------- /lib/bloc/screen/novel/novel_screen_event.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | 3 | sealed class NovelScreenEvent extends Equatable { 4 | @override 5 | List get props => []; 6 | } 7 | 8 | final class NovelScreenRefreshed extends NovelScreenEvent {} 9 | 10 | final class NovelScreenChapterRead extends NovelScreenEvent { 11 | final int chapterId; 12 | 13 | NovelScreenChapterRead({required this.chapterId}); 14 | 15 | @override 16 | List get props => [chapterId]; 17 | } 18 | 19 | final class NovelScreenNovelFavorited extends NovelScreenEvent {} 20 | 21 | final class NovelScreenNovelUnfavorited extends NovelScreenEvent {} 22 | -------------------------------------------------------------------------------- /lib/bloc/screen/novel/novel_screen_state.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:masiro/data/repository/model/novel_detail.dart'; 3 | 4 | sealed class NovelScreenState extends Equatable { 5 | @override 6 | List get props => []; 7 | } 8 | 9 | class NovelScreenInitialState extends NovelScreenState {} 10 | 11 | class NovelScreenErrorState extends NovelScreenState { 12 | final String? message; 13 | 14 | NovelScreenErrorState({this.message}); 15 | 16 | @override 17 | List get props => [message]; 18 | } 19 | 20 | class NovelScreenLoadedState extends NovelScreenState { 21 | final NovelDetail novelDetail; 22 | 23 | NovelScreenLoadedState({required this.novelDetail}); 24 | 25 | NovelScreenLoadedState copyWith({NovelDetail? novelDetail}) { 26 | return NovelScreenLoadedState(novelDetail: novelDetail ?? this.novelDetail); 27 | } 28 | 29 | @override 30 | List get props => [novelDetail]; 31 | } 32 | -------------------------------------------------------------------------------- /lib/bloc/screen/reader/reader_screen_event.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:masiro/data/repository/model/read_position.dart'; 3 | 4 | sealed class ReaderScreenEvent extends Equatable { 5 | @override 6 | List get props => []; 7 | } 8 | 9 | final class ReaderScreenChapterDetailRequested extends ReaderScreenEvent { 10 | final int chapterId; 11 | 12 | ReaderScreenChapterDetailRequested({required this.chapterId}); 13 | 14 | @override 15 | List get props => [chapterId]; 16 | } 17 | 18 | final class ReaderScreenHudToggled extends ReaderScreenEvent {} 19 | 20 | final class ReaderScreenPositionChanged extends ReaderScreenEvent { 21 | final ReadPosition position; 22 | 23 | ReaderScreenPositionChanged({required this.position}); 24 | 25 | @override 26 | List get props => [position]; 27 | } 28 | 29 | final class ReaderScreenChapterNavigated extends ReaderScreenEvent { 30 | final int chapterId; 31 | 32 | ReaderScreenChapterNavigated({required this.chapterId}); 33 | 34 | @override 35 | List get props => [chapterId]; 36 | } 37 | 38 | final class ReaderScreenFontSizeChanged extends ReaderScreenEvent { 39 | final int fontSize; 40 | 41 | ReaderScreenFontSizeChanged({required this.fontSize}); 42 | 43 | @override 44 | List get props => [fontSize]; 45 | } 46 | -------------------------------------------------------------------------------- /lib/bloc/screen/reader/reader_screen_state.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:masiro/data/repository/model/chapter_detail.dart'; 3 | import 'package:masiro/data/repository/model/loading_status.dart'; 4 | import 'package:masiro/data/repository/model/read_position.dart'; 5 | import 'package:masiro/data/repository/model/reading_mode.dart'; 6 | 7 | sealed class ReaderScreenState extends Equatable { 8 | @override 9 | List get props => []; 10 | } 11 | 12 | class ReaderScreenInitialState extends ReaderScreenState {} 13 | 14 | class ReaderScreenErrorState extends ReaderScreenState { 15 | final String? message; 16 | 17 | ReaderScreenErrorState({this.message}); 18 | 19 | @override 20 | List get props => [message]; 21 | } 22 | 23 | class ReaderScreenLoadedState extends ReaderScreenState { 24 | final ChapterDetail chapterDetail; 25 | final bool isHudVisible; 26 | final ReadingMode readingMode; 27 | final ReadPosition position; 28 | final LoadingStatus loadingStatus; 29 | final int fontSize; 30 | 31 | ReaderScreenLoadedState({ 32 | required this.chapterDetail, 33 | this.isHudVisible = false, 34 | this.readingMode = ReadingMode.scroll, 35 | required this.position, 36 | this.loadingStatus = LoadingStatus.success, 37 | required this.fontSize, 38 | }); 39 | 40 | ReaderScreenLoadedState copyWith({ 41 | ChapterDetail? chapterDetail, 42 | bool? isHudVisible, 43 | ReadingMode? readingMode, 44 | ReadPosition? position, 45 | LoadingStatus? loadingStatus, 46 | int? fontSize, 47 | }) { 48 | return ReaderScreenLoadedState( 49 | chapterDetail: chapterDetail ?? this.chapterDetail, 50 | isHudVisible: isHudVisible ?? this.isHudVisible, 51 | readingMode: readingMode ?? this.readingMode, 52 | position: position ?? this.position, 53 | loadingStatus: loadingStatus ?? this.loadingStatus, 54 | fontSize: fontSize ?? this.fontSize, 55 | ); 56 | } 57 | 58 | @override 59 | List get props => [ 60 | chapterDetail, 61 | isHudVisible, 62 | readingMode, 63 | position, 64 | loadingStatus, 65 | fontSize, 66 | ]; 67 | } 68 | -------------------------------------------------------------------------------- /lib/bloc/screen/search/search_screen_bloc.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_bloc/flutter_bloc.dart'; 2 | import 'package:masiro/bloc/screen/search/search_screen_event.dart'; 3 | import 'package:masiro/bloc/screen/search/search_screen_state.dart'; 4 | import 'package:masiro/data/repository/masiro_repository.dart'; 5 | import 'package:masiro/di/get_it.dart'; 6 | 7 | class SearchScreenBloc extends Bloc { 8 | final masiroRepository = getIt(); 9 | 10 | SearchScreenBloc() : super(SearchScreenLoadedState()) { 11 | on(_onSearchScreenSearched); 12 | on(_onSearchScreenBottomReached); 13 | } 14 | 15 | Future _onSearchScreenSearched( 16 | SearchScreenSearched event, 17 | Emitter emit, 18 | ) async { 19 | try { 20 | emit(SearchScreenLoadingState()); 21 | final keyword = event.keyword; 22 | final result = await masiroRepository.searchNovels( 23 | keyword: keyword, 24 | page: 1, 25 | ); 26 | emit( 27 | SearchScreenLoadedState( 28 | keyword: keyword, 29 | novels: result.data, 30 | page: 1, 31 | totalCount: result.totalCount, 32 | totalPages: result.totalPages, 33 | infiniteListStatus: InfiniteListStatus.success, 34 | ), 35 | ); 36 | } catch (e) { 37 | emit(SearchScreenErrorState(message: e.toString())); 38 | } 39 | } 40 | 41 | Future _onSearchScreenBottomReached( 42 | SearchScreenBottomReached event, 43 | Emitter emit, 44 | ) async { 45 | if (state is! SearchScreenLoadedState) { 46 | return; 47 | } 48 | final loadedState = state as SearchScreenLoadedState; 49 | if (loadedState.infiniteListStatus == InfiniteListStatus.loading) { 50 | return; 51 | } 52 | if (loadedState.page >= loadedState.totalPages) { 53 | return; 54 | } 55 | try { 56 | emit( 57 | loadedState.copyWith(infiniteListStatus: InfiniteListStatus.loading), 58 | ); 59 | final nextPage = loadedState.page + 1; 60 | final result = await masiroRepository.searchNovels( 61 | keyword: loadedState.keyword, 62 | page: nextPage, 63 | ); 64 | final novels = loadedState.novels..addAll(result.data); 65 | emit( 66 | loadedState.copyWith( 67 | novels: novels, 68 | page: nextPage, 69 | infiniteListStatus: InfiniteListStatus.success, 70 | ), 71 | ); 72 | } catch (e) { 73 | emit( 74 | loadedState.copyWith(infiniteListStatus: InfiniteListStatus.failure), 75 | ); 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /lib/bloc/screen/search/search_screen_event.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | 3 | sealed class SearchScreenEvent extends Equatable { 4 | @override 5 | List get props => []; 6 | } 7 | 8 | final class SearchScreenSearched extends SearchScreenEvent { 9 | final String keyword; 10 | 11 | SearchScreenSearched({required this.keyword}); 12 | 13 | @override 14 | List get props => [keyword]; 15 | } 16 | 17 | final class SearchScreenBottomReached extends SearchScreenEvent {} 18 | -------------------------------------------------------------------------------- /lib/bloc/screen/search/search_screen_state.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:masiro/data/repository/model/novel.dart'; 3 | 4 | sealed class SearchScreenState extends Equatable { 5 | @override 6 | List get props => []; 7 | } 8 | 9 | class SearchScreenLoadingState extends SearchScreenState {} 10 | 11 | class SearchScreenErrorState extends SearchScreenState { 12 | final String? message; 13 | 14 | SearchScreenErrorState({this.message}); 15 | 16 | @override 17 | List get props => [message]; 18 | } 19 | 20 | class SearchScreenLoadedState extends SearchScreenState { 21 | final String keyword; 22 | 23 | /// Stores the search results of novels 24 | final List novels; 25 | 26 | /// Stores the current page number 27 | final int page; 28 | 29 | final int totalCount; 30 | 31 | final int totalPages; 32 | 33 | /// Indicates the loading status of the infinitely scrollable list of novels 34 | final InfiniteListStatus infiniteListStatus; 35 | 36 | SearchScreenLoadedState({ 37 | this.keyword = '', 38 | this.novels = const [], 39 | this.page = 1, 40 | this.totalCount = 0, 41 | this.totalPages = 0, 42 | this.infiniteListStatus = InfiniteListStatus.success, 43 | }); 44 | 45 | SearchScreenLoadedState copyWith({ 46 | String? keyword, 47 | List? novels, 48 | int? page, 49 | int? totalCount, 50 | int? totalPages, 51 | InfiniteListStatus? infiniteListStatus, 52 | }) { 53 | return SearchScreenLoadedState( 54 | keyword: keyword ?? this.keyword, 55 | novels: novels ?? this.novels, 56 | page: page ?? this.page, 57 | totalCount: totalCount ?? this.totalCount, 58 | totalPages: totalPages ?? this.totalPages, 59 | infiniteListStatus: infiniteListStatus ?? this.infiniteListStatus, 60 | ); 61 | } 62 | 63 | @override 64 | List get props => [ 65 | keyword, 66 | novels, 67 | page, 68 | totalCount, 69 | totalPages, 70 | infiniteListStatus, 71 | ]; 72 | } 73 | 74 | enum InfiniteListStatus { loading, success, failure } 75 | -------------------------------------------------------------------------------- /lib/bloc/screen/settings/settings_screen_bloc.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_bloc/flutter_bloc.dart'; 2 | import 'package:masiro/bloc/screen/settings/settings_screen_event.dart'; 3 | import 'package:masiro/bloc/screen/settings/settings_screen_state.dart'; 4 | import 'package:masiro/data/repository/app_configuration_repository.dart'; 5 | import 'package:masiro/data/repository/novel_record_repository.dart'; 6 | import 'package:masiro/data/repository/profile_repository.dart'; 7 | import 'package:masiro/di/get_it.dart'; 8 | 9 | typedef _SettingsScreenBloc = Bloc; 10 | 11 | class SettingsScreenBloc extends _SettingsScreenBloc { 12 | final appConfigurationRepository = getIt(); 13 | final novelRecordRepository = getIt(); 14 | final profileRepository = getIt(); 15 | 16 | SettingsScreenBloc() : super(const SettingsScreenState()) { 17 | on(_onSettingsScreenInitialized); 18 | on(_onSettingsScreenProfileRequested); 19 | on(_onSettingsScreenProfileRefreshed); 20 | } 21 | 22 | Future _onSettingsScreenInitialized( 23 | SettingsScreenInitialized event, 24 | Emitter emit, 25 | ) async { 26 | final config = await appConfigurationRepository.getAppConfiguration(); 27 | emit(state.copyWith(config: config)); 28 | } 29 | 30 | Future _onSettingsScreenProfileRequested( 31 | SettingsScreenProfileRequested event, 32 | Emitter emit, 33 | ) async { 34 | final profile = await profileRepository.getProfile(); 35 | emit(state.copyWith(profile: profile)); 36 | } 37 | 38 | Future _onSettingsScreenProfileRefreshed( 39 | SettingsScreenProfileRefreshed event, 40 | Emitter emit, 41 | ) async { 42 | final profile = await profileRepository.refreshProfile(); 43 | emit(state.copyWith(profile: profile)); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/bloc/screen/settings/settings_screen_event.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | 3 | sealed class SettingsScreenEvent extends Equatable { 4 | @override 5 | List get props => []; 6 | } 7 | 8 | final class SettingsScreenInitialized extends SettingsScreenEvent {} 9 | 10 | final class SettingsScreenProfileRequested extends SettingsScreenEvent {} 11 | 12 | final class SettingsScreenProfileRefreshed extends SettingsScreenEvent {} 13 | -------------------------------------------------------------------------------- /lib/bloc/screen/settings/settings_screen_state.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:masiro/data/repository/model/app_configuration.dart'; 3 | import 'package:masiro/data/repository/model/profile.dart'; 4 | 5 | class SettingsScreenState extends Equatable { 6 | final Profile? profile; 7 | final AppConfiguration? config; 8 | 9 | const SettingsScreenState({ 10 | this.profile, 11 | this.config, 12 | }); 13 | 14 | SettingsScreenState copyWith({Profile? profile, AppConfiguration? config}) { 15 | return SettingsScreenState( 16 | profile: profile ?? this.profile, 17 | config: config ?? this.config, 18 | ); 19 | } 20 | 21 | @override 22 | List get props => [profile, config]; 23 | } 24 | -------------------------------------------------------------------------------- /lib/bloc/util/event_transformer.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_bloc/flutter_bloc.dart'; 2 | import 'package:rxdart/transformers.dart'; 3 | 4 | EventTransformer debounce(Duration duration) { 5 | return (events, mapper) => events.debounceTime(duration).flatMap(mapper); 6 | } 7 | -------------------------------------------------------------------------------- /lib/data/database/core.dart: -------------------------------------------------------------------------------- 1 | import 'package:isar/isar.dart'; 2 | import 'package:masiro/data/database/entity/app_configuration_entity.dart'; 3 | import 'package:masiro/data/database/entity/chapter_record_entity.dart'; 4 | import 'package:masiro/data/database/entity/current_cookie_entity.dart'; 5 | import 'package:masiro/data/database/entity/novel_record_entity.dart'; 6 | import 'package:masiro/data/database/entity/user_entity.dart'; 7 | 8 | const dbCurrentVersion = 2; 9 | 10 | List> dbSchemas = [ 11 | NovelRecordEntitySchema, 12 | ChapterRecordEntitySchema, 13 | AppConfigurationEntitySchema, 14 | UserEntitySchema, 15 | CurrentCookieEntitySchema, 16 | ]; 17 | 18 | Future clearDatabase(Isar isar) async { 19 | return isar.writeTxn(() async { 20 | await isar.clear(); 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /lib/data/database/dao/app_configuration_dao.dart: -------------------------------------------------------------------------------- 1 | import 'package:injectable/injectable.dart'; 2 | import 'package:isar/isar.dart'; 3 | import 'package:masiro/data/database/entity/app_configuration_entity.dart'; 4 | 5 | @lazySingleton 6 | class AppConfigurationDao { 7 | final Isar _isar; 8 | 9 | AppConfigurationDao({required Isar isar}) : _isar = isar; 10 | 11 | Future getAppConfiguration() async { 12 | return _isar.appConfigurationEntitys.where().limit(1).findFirst(); 13 | } 14 | 15 | Future putAppConfiguration( 16 | AppConfigurationEntity appConfiguration, 17 | ) async { 18 | final configuration = await getAppConfiguration(); 19 | return _isar.writeTxn(() async { 20 | return _isar.appConfigurationEntitys.put( 21 | appConfiguration.copyWith( 22 | id: configuration?.id ?? appConfiguration.id, 23 | ), 24 | ); 25 | }); 26 | } 27 | 28 | Future updateDbVersion(int dbVersion) async { 29 | final configuration = await getAppConfiguration(); 30 | if (configuration == null) { 31 | throw Exception('''There is no app configuration to update.'''); 32 | } 33 | 34 | return _isar.writeTxn(() async { 35 | return _isar.appConfigurationEntitys.put( 36 | configuration.copyWith(dbVersion: dbVersion), 37 | ); 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /lib/data/database/dao/chapter_record_dao.dart: -------------------------------------------------------------------------------- 1 | import 'package:injectable/injectable.dart'; 2 | import 'package:isar/isar.dart'; 3 | import 'package:masiro/data/database/entity/chapter_record_entity.dart'; 4 | 5 | @injectable 6 | class ChapterRecordDao { 7 | final Isar _isar; 8 | 9 | ChapterRecordDao({required Isar isar}) : _isar = isar; 10 | 11 | Future getChapterRecord(Id id) async { 12 | return _isar.chapterRecordEntitys.get(id); 13 | } 14 | 15 | Future putChapterRecord(ChapterRecordEntity chapterRecord) async { 16 | return _isar.writeTxn(() async { 17 | return _isar.chapterRecordEntitys.put(chapterRecord); 18 | }); 19 | } 20 | 21 | Future findChapterRecord( 22 | int userId, 23 | int chapterId, 24 | ChapterReadingMode readingMode, 25 | ) async { 26 | return _isar.chapterRecordEntitys 27 | .filter() 28 | .userIdEqualTo(userId) 29 | .chapterIdEqualTo(chapterId) 30 | .readingModeEqualTo(readingMode) 31 | .findFirst(); 32 | } 33 | 34 | Future> getAllChapterRecords() async { 35 | return _isar.chapterRecordEntitys.where().findAll(); 36 | } 37 | 38 | Future> putAllChapterRecords( 39 | List chapterRecords, 40 | ) async { 41 | return _isar.writeTxn(() async { 42 | return _isar.chapterRecordEntitys.putAll(chapterRecords); 43 | }); 44 | } 45 | 46 | Future clearChapterRecords() async { 47 | return _isar.writeTxn(() async { 48 | return _isar.chapterRecordEntitys.clear(); 49 | }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/data/database/entity/app_configuration_entity.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:isar/isar.dart'; 3 | import 'package:masiro/misc/constant.dart'; 4 | 5 | part 'app_configuration_entity.g.dart'; 6 | 7 | @collection 8 | class AppConfigurationEntity { 9 | Id id; 10 | 11 | int dbVersion; 12 | 13 | @Deprecated('Use `UserEntity.lastSignInTime` instead.') 14 | int lastSignInTime; 15 | 16 | @Enumerated(EnumType.name) 17 | ThemeMode themeMode; 18 | 19 | int themeColor; 20 | 21 | int fontSize; 22 | 23 | AppConfigurationEntity({ 24 | this.id = Isar.autoIncrement, 25 | required this.dbVersion, 26 | this.lastSignInTime = 0, 27 | this.themeMode = ThemeMode.system, 28 | this.themeColor = defaultThemeColor, 29 | this.fontSize = defaultFontSize, 30 | }); 31 | 32 | AppConfigurationEntity copyWith({ 33 | Id? id, 34 | int? dbVersion, 35 | int? lastSignInTime, 36 | ThemeMode? themeMode, 37 | int? themeColor, 38 | int? fontSize, 39 | }) { 40 | return AppConfigurationEntity( 41 | id: id ?? this.id, 42 | dbVersion: dbVersion ?? this.dbVersion, 43 | lastSignInTime: lastSignInTime ?? this.lastSignInTime, 44 | themeMode: themeMode ?? this.themeMode, 45 | themeColor: themeColor ?? this.themeColor, 46 | fontSize: fontSize ?? this.fontSize, 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /lib/data/database/entity/chapter_record_entity.dart: -------------------------------------------------------------------------------- 1 | import 'package:isar/isar.dart'; 2 | import 'package:masiro/misc/constant.dart'; 3 | 4 | part 'chapter_record_entity.g.dart'; 5 | 6 | @collection 7 | class ChapterRecordEntity { 8 | Id id; 9 | 10 | @Index() 11 | int chapterId; 12 | 13 | @Index() 14 | int novelId; 15 | 16 | @Deprecated('Use `positionJson` instead.') 17 | int progress; 18 | 19 | @Enumerated(EnumType.name) 20 | late ChapterReadingMode readingMode; 21 | 22 | String? positionJson; 23 | 24 | @Index() 25 | int userId; 26 | 27 | ChapterRecordEntity({ 28 | this.id = Isar.autoIncrement, 29 | required this.chapterId, 30 | required this.novelId, 31 | required this.progress, 32 | required this.readingMode, 33 | this.positionJson, 34 | this.userId = defaultUserId, 35 | }); 36 | } 37 | 38 | enum ChapterReadingMode { 39 | page, 40 | scroll, 41 | } 42 | -------------------------------------------------------------------------------- /lib/data/database/entity/current_cookie_entity.dart: -------------------------------------------------------------------------------- 1 | import 'package:isar/isar.dart'; 2 | import 'package:masiro/data/database/entity/user_entity.dart'; 3 | 4 | part 'current_cookie_entity.g.dart'; 5 | 6 | @collection 7 | class CurrentCookieEntity { 8 | Id id; 9 | 10 | @Index() 11 | int userId; 12 | 13 | List cookieList; 14 | 15 | CurrentCookieEntity({ 16 | this.id = Isar.autoIncrement, 17 | required this.userId, 18 | this.cookieList = const [], 19 | }); 20 | 21 | CurrentCookieEntity copyWith({ 22 | Id? id, 23 | int? userId, 24 | List? cookieList, 25 | }) { 26 | return CurrentCookieEntity( 27 | id: id ?? this.id, 28 | userId: userId ?? this.userId, 29 | cookieList: cookieList ?? this.cookieList, 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/data/database/entity/novel_record_entity.dart: -------------------------------------------------------------------------------- 1 | import 'package:isar/isar.dart'; 2 | import 'package:masiro/data/database/entity/chapter_record_entity.dart'; 3 | 4 | part 'novel_record_entity.g.dart'; 5 | 6 | @collection 7 | class NovelRecordEntity { 8 | Id novelId; 9 | 10 | final chapterRecords = IsarLinks(); 11 | 12 | @Index() 13 | int userId; 14 | 15 | NovelRecordEntity({required this.novelId, required this.userId}); 16 | } 17 | -------------------------------------------------------------------------------- /lib/data/database/entity/user_entity.dart: -------------------------------------------------------------------------------- 1 | import 'package:isar/isar.dart'; 2 | 3 | part 'user_entity.g.dart'; 4 | 5 | @collection 6 | class UserEntity { 7 | @Index() 8 | Id userId; 9 | 10 | String userName; 11 | 12 | List cookieList; 13 | 14 | int lastSignInTime; 15 | 16 | UserEntity({ 17 | required this.userId, 18 | required this.userName, 19 | this.cookieList = const [], 20 | this.lastSignInTime = 0, 21 | }); 22 | 23 | UserEntity copyWith({ 24 | int? userId, 25 | String? userName, 26 | List? cookieList, 27 | int? lastSignInTime, 28 | }) { 29 | return UserEntity( 30 | userId: userId ?? this.userId, 31 | userName: userName ?? this.userName, 32 | cookieList: cookieList ?? this.cookieList, 33 | lastSignInTime: lastSignInTime ?? this.lastSignInTime, 34 | ); 35 | } 36 | } 37 | 38 | @embedded 39 | class KeyValuePair { 40 | late String key; 41 | late String value; 42 | } 43 | 44 | KeyValuePair keyValuePair(String key, String value) { 45 | final pair = KeyValuePair(); 46 | pair.key = key; 47 | pair.value = value; 48 | return pair; 49 | } 50 | -------------------------------------------------------------------------------- /lib/data/database/migration/migration_1_to_2.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:isar/isar.dart'; 4 | import 'package:logger/logger.dart'; 5 | import 'package:masiro/data/database/dao/chapter_record_dao.dart'; 6 | import 'package:masiro/data/database/dao/user_dao.dart'; 7 | import 'package:masiro/data/database/entity/current_cookie_entity.dart'; 8 | import 'package:masiro/data/database/entity/user_entity.dart'; 9 | import 'package:masiro/data/database/migration/migration.dart'; 10 | import 'package:masiro/data/repository/profile_repository.dart'; 11 | import 'package:masiro/di/get_it.dart'; 12 | import 'package:masiro/misc/constant.dart'; 13 | import 'package:path/path.dart'; 14 | import 'package:path_provider/path_provider.dart'; 15 | 16 | final migration1To2 = Migration( 17 | startVersion: 1, 18 | endVersion: 2, 19 | migrate: (Isar isar) async { 20 | final logger = getIt(); 21 | final userDao = getIt(); 22 | final chapterRecordDao = getIt(); 23 | final profileRepository = getIt(); 24 | 25 | final appDir = await getApplicationSupportDirectory(); 26 | final cookieDir = Directory(join(appDir.path, 'ie0_ps1')); 27 | final isValidDir = await cookieDir.exists(); 28 | 29 | if (!isValidDir) { 30 | return; 31 | } 32 | 33 | final cookieFiles = cookieDir.list(); 34 | final cookieList = []; 35 | await for (final entity in cookieFiles) { 36 | if (entity is! File) { 37 | continue; 38 | } 39 | final key = basename(entity.path); 40 | final value = await entity.readAsString(); 41 | cookieList.add(keyValuePair(key, value)); 42 | } 43 | 44 | if (cookieList.isEmpty) { 45 | return; 46 | } 47 | 48 | final currentCookie = CurrentCookieEntity( 49 | userId: defaultUserId, 50 | cookieList: cookieList, 51 | ); 52 | await userDao.putCurrentCookie(currentCookie); 53 | logger.d('The local cookies have been moved into the database.'); 54 | 55 | final profile = await profileRepository.refreshProfile(); 56 | final userId = profile.id; 57 | final userName = profile.name; 58 | logger.d('The current cookies belong to user $userId - $userName'); 59 | 60 | final currentUser = await userDao.getUser(userId) ?? 61 | UserEntity( 62 | userId: userId, 63 | userName: userName, 64 | ); 65 | await userDao.putUser(currentUser.copyWith(cookieList: cookieList)); 66 | await userDao.putCurrentCookie(currentCookie.copyWith(userId: userId)); 67 | logger.d('The current user information has been saved to the database.'); 68 | 69 | final chapterRecords = await chapterRecordDao.getAllChapterRecords(); 70 | for (final record in chapterRecords) { 71 | record.userId = userId; 72 | } 73 | await chapterRecordDao.putAllChapterRecords(chapterRecords); 74 | logger.d( 75 | 'The current user id has been assigned to all chapter history records.', 76 | ); 77 | }, 78 | ); 79 | -------------------------------------------------------------------------------- /lib/data/network/response/chapter_detail_response.dart: -------------------------------------------------------------------------------- 1 | import 'package:html/dom.dart'; 2 | import 'package:html/parser.dart'; 3 | import 'package:masiro/data/network/response/volume_response.dart'; 4 | 5 | class ChapterDetailResponse { 6 | int chapterId; 7 | String title; 8 | String textContent; 9 | String csrfToken; 10 | List volumes; 11 | List chapters; 12 | String rawHtml; 13 | 14 | PaymentInfoResponse? paymentInfo; 15 | 16 | ChapterDetailResponse({ 17 | required this.chapterId, 18 | required this.title, 19 | required this.textContent, 20 | required this.csrfToken, 21 | required this.volumes, 22 | required this.chapters, 23 | required this.rawHtml, 24 | required this.paymentInfo, 25 | }); 26 | 27 | factory ChapterDetailResponse.fromHtml(String html) { 28 | final document = parse(html); 29 | final querySelector = document.querySelector; 30 | final querySelectorAll = document.querySelectorAll; 31 | final chapterId = querySelector('#chapter_id')?.attributes['value'] ?? '0'; 32 | final title = querySelector('.novel-title')?.text.trim(); 33 | final textContent = querySelector('.nvl-content')?.text.trim() ?? ''; 34 | final csrfToken = querySelector('input.csrf')?.attributes['value'] ?? ''; 35 | final volumesJson = document.getElementById('f-chapters-json')?.text; 36 | final List volumes = 37 | volumesJson != null ? volumeResponseFromJson(volumesJson) : []; 38 | final chaptersJson = document.getElementById('chapters-json')?.text; 39 | final List chapters = 40 | chaptersJson != null ? chapterResponseFromJson(chaptersJson) : []; 41 | 42 | final didPay = querySelector('p.pay') == null; 43 | final paymentInfo = 44 | didPay ? null : PaymentInfoResponse.fromDocument(document); 45 | final titleHint = querySelectorAll('div.hint p a').lastOrNull?.text; 46 | 47 | return ChapterDetailResponse( 48 | chapterId: int.parse(chapterId), 49 | title: title ?? titleHint ?? '', 50 | textContent: textContent, 51 | csrfToken: csrfToken, 52 | volumes: volumes, 53 | chapters: chapters, 54 | rawHtml: html, 55 | paymentInfo: paymentInfo, 56 | ); 57 | } 58 | } 59 | 60 | class PaymentInfoResponse { 61 | int cost; 62 | int type; 63 | int chapterId; 64 | 65 | PaymentInfoResponse({ 66 | required this.cost, 67 | required this.type, 68 | required this.chapterId, 69 | }); 70 | 71 | factory PaymentInfoResponse.fromDocument(Document document) { 72 | final querySelector = document.querySelector; 73 | final cost = querySelector('input.cost')?.attributes['value'] ?? '0'; 74 | final type = querySelector('input.type')?.attributes['value'] ?? '0'; 75 | final objectId = 76 | querySelector('input.object_id')?.attributes['value'] ?? '0'; 77 | return PaymentInfoResponse( 78 | cost: int.parse(cost), 79 | type: int.parse(type), 80 | chapterId: int.parse(objectId), 81 | ); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /lib/data/network/response/common_response.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | CommonResponse commonResponseFromJson(String str) => 4 | CommonResponse.fromJson(json.decode(str)); 5 | 6 | String commonResponseToJson(CommonResponse data) => json.encode(data.toJson()); 7 | 8 | class CommonResponse { 9 | int code; 10 | String msg; 11 | 12 | CommonResponse({ 13 | required this.code, 14 | required this.msg, 15 | }); 16 | 17 | factory CommonResponse.fromJson(Map json) => CommonResponse( 18 | code: json['code'], 19 | msg: json['msg'], 20 | ); 21 | 22 | Map toJson() => { 23 | 'code': code, 24 | 'msg': msg, 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /lib/data/network/response/paged_comment_response.dart: -------------------------------------------------------------------------------- 1 | class PagedCommentResponse { 2 | int code; 3 | String msg; 4 | List comments; 5 | List replies; 6 | int total; 7 | int hasAuth; 8 | List rating; 9 | 10 | PagedCommentResponse({ 11 | required this.code, 12 | required this.msg, 13 | required this.comments, 14 | required this.replies, 15 | required this.total, 16 | required this.hasAuth, 17 | required this.rating, 18 | }); 19 | 20 | factory PagedCommentResponse.fromJson(Map json) { 21 | final rawTotal = json['total']; 22 | final total = rawTotal is String ? int.parse(rawTotal) : rawTotal; 23 | return PagedCommentResponse( 24 | code: json['code'], 25 | msg: json['msg'], 26 | comments: List.from( 27 | json['comments'].map((x) => CommentResponse.fromJson(x)), 28 | ), 29 | replies: List.from( 30 | json['replys'].map((x) => CommentResponse.fromJson(x)), 31 | ), 32 | total: total, 33 | hasAuth: json['has_auth'], 34 | rating: List.from(json['rating'].map((x) => x)), 35 | ); 36 | } 37 | } 38 | 39 | class CommentResponse { 40 | int id; 41 | int novelId; 42 | int userId; 43 | int parentId; 44 | String content; 45 | int ban; 46 | DateTime createdAt; 47 | int topId; 48 | int replyUser; 49 | int chapterId; 50 | dynamic type; 51 | int floor; 52 | int thumbUp; 53 | int hotComment; 54 | String name; 55 | String avatar; 56 | String? badges; 57 | 58 | CommentResponse({ 59 | required this.id, 60 | required this.novelId, 61 | required this.userId, 62 | required this.parentId, 63 | required this.content, 64 | required this.ban, 65 | required this.createdAt, 66 | required this.topId, 67 | required this.replyUser, 68 | required this.chapterId, 69 | required this.type, 70 | required this.floor, 71 | required this.thumbUp, 72 | required this.hotComment, 73 | required this.name, 74 | required this.avatar, 75 | required this.badges, 76 | }); 77 | 78 | factory CommentResponse.fromJson(Map json) => 79 | CommentResponse( 80 | id: json['id'], 81 | novelId: json['novel_id'], 82 | userId: json['user_id'], 83 | parentId: json['parent_id'], 84 | content: json['content'], 85 | ban: json['ban'], 86 | createdAt: DateTime.parse(json['created_at']), 87 | topId: json['top_id'], 88 | replyUser: json['reply_user'], 89 | chapterId: json['chapter_id'], 90 | type: json['type'], 91 | floor: json['floor'], 92 | thumbUp: json['thumb_up'], 93 | hotComment: json['hot_comment'], 94 | name: json['name'], 95 | avatar: json['avatar'], 96 | badges: json['badges'], 97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /lib/data/network/response/paged_novel_response.dart: -------------------------------------------------------------------------------- 1 | class PagedNovelResponse { 2 | int code; 3 | String msg; 4 | List novels; 5 | String page; 6 | int pages; 7 | int total; 8 | 9 | PagedNovelResponse({ 10 | required this.code, 11 | required this.msg, 12 | required this.novels, 13 | required this.page, 14 | required this.pages, 15 | required this.total, 16 | }); 17 | 18 | factory PagedNovelResponse.fromJson(Map json) { 19 | return PagedNovelResponse( 20 | code: json['code'], 21 | msg: json['msg'], 22 | novels: (json['novels'] as List) 23 | .map((e) => NovelResponse.fromJson(e)) 24 | .toList(), 25 | page: json['page'], 26 | pages: json['pages'], 27 | total: json['total'], 28 | ); 29 | } 30 | } 31 | 32 | class NovelResponse { 33 | int id; 34 | String title; 35 | String brief; 36 | int rank; 37 | String coverImg; 38 | String? author; 39 | String? newUpTime; 40 | String? newUpContent; 41 | int lvLimit; 42 | 43 | NovelResponse({ 44 | required this.id, 45 | required this.title, 46 | required this.brief, 47 | required this.rank, 48 | required this.coverImg, 49 | required this.author, 50 | required this.newUpTime, 51 | required this.newUpContent, 52 | required this.lvLimit, 53 | }); 54 | 55 | factory NovelResponse.fromJson(Map json) { 56 | final rawLvLimit = json['lv_limit']; 57 | final lvLimit = rawLvLimit is String ? int.parse(rawLvLimit) : rawLvLimit; 58 | return NovelResponse( 59 | id: json['id'], 60 | title: json['title'], 61 | brief: json['brief'], 62 | rank: json['rank'], 63 | coverImg: json['cover_img'], 64 | author: json['author'], 65 | newUpTime: json['new_up_time'], 66 | newUpContent: json['new_up_content'], 67 | lvLimit: lvLimit, 68 | ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /lib/data/network/response/profile_response.dart: -------------------------------------------------------------------------------- 1 | import 'package:html/parser.dart'; 2 | import 'package:masiro/misc/url.dart'; 3 | 4 | class ProfileResponse { 5 | int id; 6 | String name; 7 | int level; 8 | String avatar; 9 | int coinCount; 10 | int fanCount; 11 | 12 | ProfileResponse({ 13 | required this.id, 14 | required this.name, 15 | required this.level, 16 | required this.avatar, 17 | required this.coinCount, 18 | required this.fanCount, 19 | }); 20 | 21 | factory ProfileResponse.fromHtml(String html) { 22 | final document = parse(html); 23 | final querySelector = document.querySelector; 24 | final numberPattern = RegExp(r'\d+'); 25 | final id = querySelector('li.user-header small small') 26 | ?.text 27 | .split(':')[1] 28 | .trim() ?? 29 | '0'; 30 | final name = 31 | querySelector('li.user-header p')?.firstChild?.text?.trim() ?? ''; 32 | final level = querySelector('.user-lev')?.text.substring(2) ?? '0'; 33 | final avatar = querySelector('img.user-image')?.attributes['src']; 34 | final coinNodeText = 35 | querySelector('li.user-header small')?.firstChild?.text ?? ''; 36 | final coinCount = 37 | numberPattern.allMatches(coinNodeText).firstOrNull?.group(0) ?? '0'; 38 | final fanCount = 39 | querySelector('span.fans')?.firstChild?.text?.trim() ?? '0'; 40 | 41 | return ProfileResponse( 42 | id: int.parse(id), 43 | name: name, 44 | level: int.parse(level), 45 | avatar: avatar ?? MasiroUrl.defaultAvatar, 46 | coinCount: int.parse(coinCount), 47 | fanCount: int.parse(fanCount), 48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /lib/data/network/response/volume_response.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | class VolumeResponse { 4 | int id; 5 | String title; 6 | dynamic describe; 7 | 8 | VolumeResponse({ 9 | required this.id, 10 | required this.title, 11 | required this.describe, 12 | }); 13 | 14 | factory VolumeResponse.fromJson(Map json) => VolumeResponse( 15 | id: json['id'], 16 | title: json['title'], 17 | describe: json['describe'], 18 | ); 19 | } 20 | 21 | List volumeResponseFromJson(String str) => 22 | List.from( 23 | json.decode(str).map((x) => VolumeResponse.fromJson(x)), 24 | ); 25 | 26 | class ChapterResponse { 27 | int id; 28 | int novelId; 29 | int parentId; 30 | String title; 31 | int creator; 32 | dynamic describe; 33 | int limitLv; 34 | int cost; 35 | int? translator; 36 | int? porter; 37 | dynamic proofreader; 38 | dynamic polish; 39 | DateTime episodeCreateTime; 40 | DateTime episodeUpdateTime; 41 | 42 | ChapterResponse({ 43 | required this.id, 44 | required this.novelId, 45 | required this.parentId, 46 | required this.title, 47 | required this.creator, 48 | required this.describe, 49 | required this.limitLv, 50 | required this.cost, 51 | required this.translator, 52 | required this.porter, 53 | required this.proofreader, 54 | required this.polish, 55 | required this.episodeCreateTime, 56 | required this.episodeUpdateTime, 57 | }); 58 | 59 | factory ChapterResponse.fromJson(Map json) => 60 | ChapterResponse( 61 | id: json['id'], 62 | novelId: json['novel_id'], 63 | parentId: json['parent_id'], 64 | title: json['title'], 65 | creator: json['creator'], 66 | describe: json['describe'], 67 | limitLv: json['limit_lv'], 68 | cost: json['cost'], 69 | translator: json['translator'], 70 | porter: json['porter'], 71 | proofreader: json['proofreader'], 72 | polish: json['polish'], 73 | episodeCreateTime: DateTime.parse(json['episode_create_time']), 74 | episodeUpdateTime: DateTime.parse(json['episode_update_time']), 75 | ); 76 | } 77 | 78 | List chapterResponseFromJson(String str) => 79 | List.from( 80 | json.decode(str).map((x) => ChapterResponse.fromJson(x)), 81 | ); 82 | -------------------------------------------------------------------------------- /lib/data/repository/adapter/app_configuration_entity_adapter.dart: -------------------------------------------------------------------------------- 1 | import 'package:masiro/data/database/entity/app_configuration_entity.dart'; 2 | import 'package:masiro/data/repository/model/app_configuration.dart'; 3 | 4 | AppConfiguration appConfigurationEntityToModel(AppConfigurationEntity entity) { 5 | return AppConfiguration( 6 | id: entity.id, 7 | dbVersion: entity.dbVersion, 8 | themeMode: entity.themeMode, 9 | themeColor: entity.themeColor, 10 | fontSize: entity.fontSize, 11 | ); 12 | } 13 | 14 | AppConfigurationEntity appConfigurationToEntity(AppConfiguration config) { 15 | return AppConfigurationEntity( 16 | id: config.id, 17 | dbVersion: config.dbVersion, 18 | themeMode: config.themeMode, 19 | themeColor: config.themeColor, 20 | fontSize: config.fontSize, 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /lib/data/repository/adapter/chapter_record_entity_adapter.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:masiro/data/database/entity/chapter_record_entity.dart'; 4 | import 'package:masiro/data/repository/model/chapter_record.dart'; 5 | import 'package:masiro/data/repository/model/read_position.dart'; 6 | import 'package:masiro/data/repository/model/reading_mode.dart'; 7 | 8 | ChapterRecord chapterRecordToModel(ChapterRecordEntity record) { 9 | return ChapterRecord( 10 | id: record.id, 11 | chapterId: record.chapterId, 12 | novelId: record.novelId, 13 | readingMode: readingModeToModel(record.readingMode), 14 | position: jsonToPosition(record.positionJson), 15 | userId: record.userId, 16 | ); 17 | } 18 | 19 | ReadingMode readingModeToModel(ChapterReadingMode mode) { 20 | switch (mode) { 21 | case ChapterReadingMode.page: 22 | return ReadingMode.page; 23 | case ChapterReadingMode.scroll: 24 | return ReadingMode.scroll; 25 | } 26 | } 27 | 28 | ReadPosition jsonToPosition(String? positionJson) { 29 | if (positionJson == null) { 30 | return startPosition; 31 | } 32 | final position = json.decode(positionJson); 33 | return ReadPosition( 34 | elementIndex: position['elementIndex'], 35 | elementTopOffset: position['elementTopOffset'], 36 | elementCharacterIndex: position['elementCharacterIndex'], 37 | articleCharacterIndex: position['articleCharacterIndex'], 38 | ); 39 | } 40 | 41 | ChapterRecordEntity chapterRecordToEntity(ChapterRecord record) { 42 | return ChapterRecordEntity( 43 | id: record.id, 44 | chapterId: record.chapterId, 45 | novelId: record.novelId, 46 | progress: 0, 47 | readingMode: readingModeToEntity(record.readingMode), 48 | positionJson: positionToJson(record.position), 49 | userId: record.userId, 50 | ); 51 | } 52 | 53 | ChapterReadingMode readingModeToEntity(ReadingMode mode) { 54 | switch (mode) { 55 | case ReadingMode.page: 56 | return ChapterReadingMode.page; 57 | case ReadingMode.scroll: 58 | return ChapterReadingMode.scroll; 59 | } 60 | } 61 | 62 | String positionToJson(ReadPosition position) { 63 | return json.encode({ 64 | 'elementIndex': position.elementIndex, 65 | 'elementTopOffset': position.elementTopOffset, 66 | 'elementCharacterIndex': position.elementCharacterIndex, 67 | 'articleCharacterIndex': position.articleCharacterIndex, 68 | }); 69 | } 70 | -------------------------------------------------------------------------------- /lib/data/repository/adapter/comment_response_adapter.dart: -------------------------------------------------------------------------------- 1 | import 'package:masiro/data/network/response/paged_comment_response.dart'; 2 | import 'package:masiro/data/repository/model/comment.dart'; 3 | import 'package:masiro/data/repository/model/paged_data.dart'; 4 | 5 | PagedData pagedCommentResponseToModel( 6 | PagedCommentResponse response, 7 | int page, 8 | int pageSize, 9 | ) { 10 | final comments = response.comments 11 | .where((c) => c.ban == 0) 12 | .map(commentResponseToComment) 13 | .toList(); 14 | 15 | for (final c in comments) { 16 | final replies = response.replies 17 | .where((r) => r.ban == 0 && r.topId == c.id) 18 | .map(commentResponseToComment); 19 | c.replies.addAll(replies); 20 | } 21 | 22 | return PagedData( 23 | data: comments, 24 | page: page, 25 | totalCount: response.total, 26 | totalPages: (response.total / pageSize).ceil(), 27 | ); 28 | } 29 | 30 | Comment commentResponseToComment(CommentResponse c) { 31 | return Comment( 32 | id: c.id, 33 | novelId: c.novelId, 34 | userId: c.userId, 35 | parentId: c.parentId, 36 | content: c.content, 37 | createdAt: c.createdAt, 38 | topId: c.topId, 39 | replyUser: c.replyUser, 40 | chapterId: c.chapterId, 41 | floor: c.floor, 42 | thumbUp: c.thumbUp, 43 | name: c.name, 44 | avatar: c.avatar, 45 | badges: c.badges, 46 | replies: List.empty(growable: true), 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /lib/data/repository/adapter/novel_detail_response_adapter.dart: -------------------------------------------------------------------------------- 1 | import 'package:masiro/data/network/response/novel_detail_response.dart'; 2 | import 'package:masiro/data/repository/adapter/volume_response_adapter.dart'; 3 | import 'package:masiro/data/repository/model/novel_detail.dart'; 4 | 5 | NovelDetail novelDetailResponseToNovelDetail(NovelDetailResponse d) { 6 | final header = NovelDetailHeader( 7 | title: d.header.title, 8 | author: d.header.author, 9 | translators: d.header.translators, 10 | status: d.header.status, 11 | originalBook: d.header.originalBook, 12 | brief: d.header.brief, 13 | isFavorite: d.header.isFavorite, 14 | csrfToken: d.header.csrfToken, 15 | coverImg: d.header.coverImg, 16 | ); 17 | final volumes = volumeResponseToVolumeList(d.volumes, d.chapters); 18 | return NovelDetail( 19 | volumes: volumes, 20 | header: header, 21 | lastReadChapterId: d.lastReadChapterId, 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /lib/data/repository/adapter/novel_response_adapter.dart: -------------------------------------------------------------------------------- 1 | import 'package:masiro/data/network/response/paged_novel_response.dart'; 2 | import 'package:masiro/data/repository/model/novel.dart'; 3 | 4 | Novel novelResponseToNovel(NovelResponse n) { 5 | return Novel( 6 | id: n.id, 7 | title: n.title, 8 | brief: n.brief, 9 | rank: n.rank, 10 | coverImg: n.coverImg, 11 | author: n.author, 12 | lastUpdated: n.newUpContent, 13 | lastUpdatedTime: n.newUpTime, 14 | lvLimit: n.lvLimit, 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /lib/data/repository/adapter/profile_response_adapter.dart: -------------------------------------------------------------------------------- 1 | import 'package:masiro/data/network/response/profile_response.dart'; 2 | import 'package:masiro/data/repository/model/profile.dart'; 3 | 4 | Profile profileResponseToProfile(ProfileResponse r) { 5 | return Profile( 6 | id: r.id, 7 | name: r.name, 8 | level: r.level, 9 | avatar: r.avatar, 10 | coinCount: r.coinCount, 11 | fanCount: r.fanCount, 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /lib/data/repository/adapter/user_entity_adapter.dart: -------------------------------------------------------------------------------- 1 | import 'package:masiro/data/database/entity/user_entity.dart'; 2 | import 'package:masiro/data/repository/model/user.dart'; 3 | 4 | User userEntityToModel(UserEntity entity) { 5 | return User( 6 | userId: entity.userId, 7 | userName: entity.userName, 8 | lastSignInTime: entity.lastSignInTime, 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /lib/data/repository/adapter/volume_response_adapter.dart: -------------------------------------------------------------------------------- 1 | import 'package:masiro/data/network/response/volume_response.dart'; 2 | import 'package:masiro/data/repository/model/volume.dart'; 3 | 4 | List volumeResponseToVolumeList( 5 | List volumes, 6 | List chapters, 7 | ) { 8 | final chapterModels = chapters.map( 9 | (c) => Chapter( 10 | id: c.id, 11 | novelId: c.novelId, 12 | volumeId: c.parentId, 13 | title: c.title, 14 | limitLv: c.limitLv, 15 | cost: c.cost, 16 | createdTime: c.episodeCreateTime, 17 | updatedTime: c.episodeUpdateTime, 18 | ), 19 | ); 20 | final volumeModels = volumes.map((v) { 21 | final chapterList = chapterModels.where((c) => c.volumeId == v.id).toList(); 22 | return Volume(id: v.id, title: v.title, chapters: chapterList); 23 | }).toList(); 24 | return volumeModels; 25 | } 26 | -------------------------------------------------------------------------------- /lib/data/repository/app_configuration_repository.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:injectable/injectable.dart'; 3 | import 'package:isar/isar.dart'; 4 | import 'package:masiro/data/database/dao/app_configuration_dao.dart'; 5 | import 'package:masiro/data/repository/adapter/app_configuration_entity_adapter.dart'; 6 | import 'package:masiro/data/repository/model/app_configuration.dart'; 7 | import 'package:masiro/di/get_it.dart'; 8 | 9 | @lazySingleton 10 | class AppConfigurationRepository { 11 | final _appConfigurationDao = getIt(); 12 | 13 | Future getAppConfiguration() async { 14 | final config = await _appConfigurationDao.getAppConfiguration(); 15 | // Although it's possible that there's no app configuration in the database, 16 | // we can still assert that the app configuration exists. 17 | // This is because we insert a default app configuration if it's null when the app starts, 18 | // see the function `performMigrationIfNeeded`. 19 | return appConfigurationEntityToModel(config!); 20 | } 21 | 22 | Future putAppConfiguration( 23 | AppConfiguration appConfiguration, 24 | ) async { 25 | final entity = appConfigurationToEntity(appConfiguration); 26 | return _appConfigurationDao.putAppConfiguration(entity); 27 | } 28 | 29 | Future setThemeColor(int themeColor) async { 30 | final config = await getAppConfiguration(); 31 | final nextConfig = config.copyWith(themeColor: themeColor); 32 | return putAppConfiguration(nextConfig); 33 | } 34 | 35 | Future setThemeMode(ThemeMode themeMode) async { 36 | final config = await getAppConfiguration(); 37 | final nextConfig = config.copyWith(themeMode: themeMode); 38 | return putAppConfiguration(nextConfig); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /lib/data/repository/favorites_repository.dart: -------------------------------------------------------------------------------- 1 | import 'package:injectable/injectable.dart'; 2 | import 'package:masiro/data/repository/masiro_repository.dart'; 3 | import 'package:masiro/data/repository/model/novel.dart'; 4 | import 'package:masiro/di/get_it.dart'; 5 | 6 | @lazySingleton 7 | class FavoritesRepository { 8 | final _masiroRepository = getIt(); 9 | 10 | List? _cachedFavorites; 11 | 12 | bool _needsRefresh = false; 13 | 14 | Future> getFavorites() async { 15 | if (_needsRefresh) { 16 | return refreshFavorites(); 17 | } 18 | if (_cachedFavorites != null) { 19 | return List.from(_cachedFavorites!); 20 | } 21 | _cachedFavorites = await _masiroRepository.getFavorites(); 22 | return List.from(_cachedFavorites!); 23 | } 24 | 25 | Future> refreshFavorites() async { 26 | _cachedFavorites = await _masiroRepository.getFavorites(); 27 | _needsRefresh = false; 28 | return List.from(_cachedFavorites!); 29 | } 30 | 31 | Future addToFavorites(int novelId, String csrfToken) async { 32 | await _masiroRepository.addNovelToFavorites(novelId, csrfToken); 33 | _needsRefresh = true; 34 | } 35 | 36 | Future removeFromFavorites(int novelId, String csrfToken) async { 37 | await _masiroRepository.removeNovelFromFavorites(novelId, csrfToken); 38 | _cachedFavorites?.removeWhere((novel) => novel.id == novelId); 39 | } 40 | 41 | void setNeedsRefresh(bool needsRefresh) { 42 | _needsRefresh = needsRefresh; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /lib/data/repository/model/app_configuration.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:isar/isar.dart'; 4 | 5 | class AppConfiguration extends Equatable { 6 | final Id id; 7 | final int dbVersion; 8 | final ThemeMode themeMode; 9 | final int themeColor; 10 | final int fontSize; 11 | 12 | const AppConfiguration({ 13 | required this.id, 14 | required this.dbVersion, 15 | required this.themeMode, 16 | required this.themeColor, 17 | required this.fontSize, 18 | }); 19 | 20 | AppConfiguration copyWith({ 21 | Id? id, 22 | int? dbVersion, 23 | int? lastSignInTime, 24 | ThemeMode? themeMode, 25 | int? themeColor, 26 | int? fontSize, 27 | }) { 28 | return AppConfiguration( 29 | id: id ?? this.id, 30 | dbVersion: dbVersion ?? this.dbVersion, 31 | themeMode: themeMode ?? this.themeMode, 32 | themeColor: themeColor ?? this.themeColor, 33 | fontSize: fontSize ?? this.fontSize, 34 | ); 35 | } 36 | 37 | @override 38 | List get props => [ 39 | id, 40 | dbVersion, 41 | themeMode, 42 | themeColor, 43 | fontSize, 44 | ]; 45 | } 46 | -------------------------------------------------------------------------------- /lib/data/repository/model/chapter_detail.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:masiro/data/repository/model/volume.dart'; 3 | 4 | class ChapterDetail extends Equatable { 5 | final int chapterId; 6 | final String title; 7 | final ChapterContent content; 8 | final String textContent; 9 | final List volumes; 10 | final String csrfToken; 11 | 12 | final PaymentInfo? paymentInfo; 13 | 14 | const ChapterDetail({ 15 | required this.chapterId, 16 | required this.title, 17 | required this.content, 18 | required this.textContent, 19 | required this.csrfToken, 20 | required this.volumes, 21 | required this.paymentInfo, 22 | }); 23 | 24 | @override 25 | List get props => [ 26 | chapterId, 27 | title, 28 | content, 29 | textContent, 30 | volumes, 31 | csrfToken, 32 | ]; 33 | } 34 | 35 | class ChapterContent extends Equatable { 36 | final List elements; 37 | 38 | const ChapterContent({required this.elements}); 39 | 40 | @override 41 | List get props => [elements]; 42 | } 43 | 44 | sealed class ChapterContentElement extends Equatable {} 45 | 46 | class TextContent extends ChapterContentElement { 47 | final String text; 48 | 49 | TextContent({required this.text}); 50 | 51 | TextContent copyWith({String? text}) { 52 | return TextContent(text: text ?? this.text); 53 | } 54 | 55 | @override 56 | List get props => [text]; 57 | } 58 | 59 | class ImageContent extends ChapterContentElement { 60 | final String src; 61 | 62 | ImageContent({required this.src}); 63 | 64 | @override 65 | List get props => [src]; 66 | } 67 | 68 | class PaymentInfo extends Equatable { 69 | final int cost; 70 | final int type; 71 | final int chapterId; 72 | 73 | const PaymentInfo({ 74 | required this.cost, 75 | required this.type, 76 | required this.chapterId, 77 | }); 78 | 79 | @override 80 | List get props => [ 81 | cost, 82 | type, 83 | chapterId, 84 | ]; 85 | } 86 | -------------------------------------------------------------------------------- /lib/data/repository/model/chapter_record.dart: -------------------------------------------------------------------------------- 1 | import 'package:isar/isar.dart'; 2 | import 'package:masiro/data/repository/model/read_position.dart'; 3 | import 'package:masiro/data/repository/model/reading_mode.dart'; 4 | 5 | class ChapterRecord { 6 | Id id; 7 | 8 | int chapterId; 9 | int novelId; 10 | 11 | ReadingMode readingMode; 12 | 13 | ReadPosition position; 14 | 15 | Id userId; 16 | 17 | ChapterRecord({ 18 | required this.id, 19 | required this.chapterId, 20 | required this.novelId, 21 | required this.readingMode, 22 | required this.position, 23 | required this.userId, 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /lib/data/repository/model/comment.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | 3 | class Comment extends Equatable { 4 | final int id; 5 | final int novelId; 6 | final int userId; 7 | final int parentId; 8 | final String content; 9 | final DateTime createdAt; 10 | final int topId; 11 | final int replyUser; 12 | final int chapterId; 13 | final int floor; 14 | final int thumbUp; 15 | final String name; 16 | final String avatar; 17 | final String? badges; 18 | 19 | final List replies; 20 | 21 | const Comment({ 22 | required this.id, 23 | required this.novelId, 24 | required this.userId, 25 | required this.parentId, 26 | required this.content, 27 | required this.createdAt, 28 | required this.topId, 29 | required this.replyUser, 30 | required this.chapterId, 31 | required this.floor, 32 | required this.thumbUp, 33 | required this.name, 34 | required this.avatar, 35 | required this.badges, 36 | required this.replies, 37 | }); 38 | 39 | @override 40 | List get props => [ 41 | id, 42 | novelId, 43 | userId, 44 | parentId, 45 | content, 46 | createdAt, 47 | topId, 48 | replyUser, 49 | chapterId, 50 | floor, 51 | thumbUp, 52 | name, 53 | avatar, 54 | badges, 55 | replies, 56 | ]; 57 | } 58 | -------------------------------------------------------------------------------- /lib/data/repository/model/loading_status.dart: -------------------------------------------------------------------------------- 1 | enum LoadingStatus { 2 | loading, 3 | success, 4 | failure; 5 | 6 | bool isLoading() { 7 | return this == LoadingStatus.loading; 8 | } 9 | 10 | bool isSuccess() { 11 | return this == LoadingStatus.success; 12 | } 13 | 14 | bool isFailure() { 15 | return this == LoadingStatus.failure; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /lib/data/repository/model/novel.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | 3 | final class Novel extends Equatable { 4 | final int id; 5 | final String title; 6 | final String brief; 7 | final int rank; 8 | final String coverImg; 9 | final String? author; 10 | final String? lastUpdated; 11 | final String? lastUpdatedTime; 12 | final int lvLimit; 13 | 14 | const Novel({ 15 | required this.id, 16 | required this.title, 17 | required this.brief, 18 | required this.rank, 19 | required this.coverImg, 20 | required this.author, 21 | required this.lastUpdated, 22 | required this.lastUpdatedTime, 23 | required this.lvLimit, 24 | }); 25 | 26 | @override 27 | List get props => [ 28 | id, 29 | title, 30 | brief, 31 | rank, 32 | coverImg, 33 | author, 34 | lastUpdated, 35 | lastUpdatedTime, 36 | lvLimit, 37 | ]; 38 | } 39 | -------------------------------------------------------------------------------- /lib/data/repository/model/novel_detail.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:masiro/data/repository/model/volume.dart'; 3 | 4 | class NovelDetail extends Equatable { 5 | final NovelDetailHeader header; 6 | final List volumes; 7 | final int lastReadChapterId; 8 | 9 | const NovelDetail({ 10 | required this.volumes, 11 | required this.header, 12 | required this.lastReadChapterId, 13 | }); 14 | 15 | NovelDetail copyWith({ 16 | NovelDetailHeader? header, 17 | List? volumes, 18 | int? lastReadChapterId, 19 | }) { 20 | return NovelDetail( 21 | header: header ?? this.header, 22 | volumes: volumes ?? this.volumes, 23 | lastReadChapterId: lastReadChapterId ?? this.lastReadChapterId, 24 | ); 25 | } 26 | 27 | @override 28 | List get props => [header, volumes, lastReadChapterId]; 29 | } 30 | 31 | class NovelDetailHeader extends Equatable { 32 | final String title; 33 | final String author; 34 | final List translators; 35 | final String status; 36 | final String originalBook; 37 | final String brief; 38 | final bool isFavorite; 39 | final String csrfToken; 40 | final String coverImg; 41 | 42 | const NovelDetailHeader({ 43 | required this.title, 44 | required this.author, 45 | required this.translators, 46 | required this.status, 47 | required this.originalBook, 48 | required this.brief, 49 | required this.isFavorite, 50 | required this.csrfToken, 51 | required this.coverImg, 52 | }); 53 | 54 | NovelDetailHeader copyWith({ 55 | String? title, 56 | String? author, 57 | List? translators, 58 | String? status, 59 | String? originalBook, 60 | String? brief, 61 | bool? isFavorite, 62 | String? csrfToken, 63 | String? coverImg, 64 | }) { 65 | return NovelDetailHeader( 66 | title: title ?? this.title, 67 | author: author ?? this.author, 68 | translators: translators ?? this.translators, 69 | status: status ?? this.status, 70 | originalBook: originalBook ?? this.originalBook, 71 | brief: brief ?? this.brief, 72 | isFavorite: isFavorite ?? this.isFavorite, 73 | csrfToken: csrfToken ?? this.csrfToken, 74 | coverImg: coverImg ?? this.coverImg, 75 | ); 76 | } 77 | 78 | @override 79 | List get props => [ 80 | title, 81 | author, 82 | translators, 83 | status, 84 | originalBook, 85 | brief, 86 | isFavorite, 87 | csrfToken, 88 | coverImg, 89 | ]; 90 | } 91 | -------------------------------------------------------------------------------- /lib/data/repository/model/paged_data.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | 3 | class PagedData extends Equatable { 4 | final List data; 5 | final int page; 6 | final int totalCount; 7 | final int totalPages; 8 | 9 | const PagedData({ 10 | required this.data, 11 | required this.page, 12 | required this.totalCount, 13 | required this.totalPages, 14 | }); 15 | 16 | @override 17 | List get props => [data, page, totalCount, totalPages]; 18 | } 19 | -------------------------------------------------------------------------------- /lib/data/repository/model/profile.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | 3 | class Profile extends Equatable { 4 | final int id; 5 | final String name; 6 | final int level; 7 | final String avatar; 8 | final int coinCount; 9 | final int fanCount; 10 | 11 | const Profile({ 12 | required this.id, 13 | required this.name, 14 | required this.level, 15 | required this.avatar, 16 | required this.coinCount, 17 | required this.fanCount, 18 | }); 19 | 20 | @override 21 | List get props => [ 22 | id, 23 | name, 24 | level, 25 | avatar, 26 | coinCount, 27 | fanCount, 28 | ]; 29 | } 30 | -------------------------------------------------------------------------------- /lib/data/repository/model/read_position.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | 3 | const startPosition = ReadPosition( 4 | elementIndex: 0, 5 | elementTopOffset: 0, 6 | elementCharacterIndex: 0, 7 | articleCharacterIndex: 0, 8 | ); 9 | 10 | class ReadPosition extends Equatable { 11 | final int elementIndex; 12 | final double elementTopOffset; 13 | final int? elementCharacterIndex; 14 | final int? articleCharacterIndex; 15 | 16 | const ReadPosition({ 17 | required this.elementIndex, 18 | required this.elementTopOffset, 19 | this.elementCharacterIndex, 20 | this.articleCharacterIndex, 21 | }); 22 | 23 | bool isTextPosition() { 24 | return elementCharacterIndex != null && articleCharacterIndex != null; 25 | } 26 | 27 | @override 28 | List get props => [ 29 | elementIndex, 30 | elementTopOffset, 31 | elementCharacterIndex, 32 | articleCharacterIndex, 33 | ]; 34 | } 35 | -------------------------------------------------------------------------------- /lib/data/repository/model/reading_mode.dart: -------------------------------------------------------------------------------- 1 | enum ReadingMode { 2 | page, 3 | scroll; 4 | 5 | bool isPage() { 6 | return this == ReadingMode.page; 7 | } 8 | 9 | bool isScroll() { 10 | return this == ReadingMode.scroll; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /lib/data/repository/model/user.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | 3 | class User extends Equatable { 4 | final int userId; 5 | final String userName; 6 | final int lastSignInTime; 7 | 8 | const User({ 9 | required this.userId, 10 | required this.userName, 11 | required this.lastSignInTime, 12 | }); 13 | 14 | User copyWith({ 15 | int? userId, 16 | String? userName, 17 | int? lastSignInTime, 18 | }) { 19 | return User( 20 | userId: userId ?? this.userId, 21 | userName: userName ?? this.userName, 22 | lastSignInTime: lastSignInTime ?? this.lastSignInTime, 23 | ); 24 | } 25 | 26 | @override 27 | List get props => [ 28 | userId, 29 | userName, 30 | lastSignInTime, 31 | ]; 32 | } 33 | -------------------------------------------------------------------------------- /lib/data/repository/model/volume.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | 3 | class Volume extends Equatable { 4 | final int id; 5 | final String title; 6 | final List chapters; 7 | 8 | const Volume({ 9 | required this.id, 10 | required this.title, 11 | required this.chapters, 12 | }); 13 | 14 | @override 15 | List get props => [id, title, chapters]; 16 | } 17 | 18 | class Chapter extends Equatable { 19 | final int id; 20 | final int novelId; 21 | final int volumeId; 22 | final String title; 23 | final int limitLv; 24 | final int cost; 25 | final DateTime createdTime; 26 | final DateTime updatedTime; 27 | 28 | const Chapter({ 29 | required this.id, 30 | required this.novelId, 31 | required this.volumeId, 32 | required this.title, 33 | required this.limitLv, 34 | required this.cost, 35 | required this.createdTime, 36 | required this.updatedTime, 37 | }); 38 | 39 | @override 40 | List get props => [ 41 | id, 42 | novelId, 43 | volumeId, 44 | title, 45 | limitLv, 46 | cost, 47 | createdTime.millisecondsSinceEpoch, 48 | updatedTime.millisecondsSinceEpoch, 49 | ]; 50 | } 51 | -------------------------------------------------------------------------------- /lib/data/repository/novel_record_repository.dart: -------------------------------------------------------------------------------- 1 | import 'package:injectable/injectable.dart'; 2 | import 'package:isar/isar.dart'; 3 | import 'package:masiro/data/database/dao/chapter_record_dao.dart'; 4 | import 'package:masiro/data/repository/adapter/chapter_record_entity_adapter.dart'; 5 | import 'package:masiro/data/repository/model/chapter_record.dart'; 6 | import 'package:masiro/data/repository/model/reading_mode.dart'; 7 | import 'package:masiro/di/get_it.dart'; 8 | 9 | @injectable 10 | class NovelRecordRepository { 11 | final _chapterRecordDao = getIt(); 12 | 13 | Future getChapterRecord(Id recordId) async { 14 | final recordEntity = await _chapterRecordDao.getChapterRecord(recordId); 15 | return recordEntity != null ? chapterRecordToModel(recordEntity) : null; 16 | } 17 | 18 | Future putChapterRecord(ChapterRecord record) async { 19 | final recordEntity = chapterRecordToEntity(record); 20 | return _chapterRecordDao.putChapterRecord(recordEntity); 21 | } 22 | 23 | Future findChapterRecord( 24 | int userId, 25 | int chapterId, 26 | ReadingMode readingMode, 27 | ) async { 28 | final recordEntity = await _chapterRecordDao.findChapterRecord( 29 | userId, 30 | chapterId, 31 | readingModeToEntity(readingMode), 32 | ); 33 | return recordEntity != null ? chapterRecordToModel(recordEntity) : null; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /lib/data/repository/preferences_repository.dart: -------------------------------------------------------------------------------- 1 | import 'package:shared_preferences/shared_preferences.dart'; 2 | 3 | // Keys 4 | const _versionKey = 'version'; 5 | const _languageKey = 'language'; 6 | 7 | // Represents the current version of the shared preferences data 8 | const _currentVersion = 10; 9 | 10 | /// Manages all the shared preferences data used by the application 11 | class PreferencesRepository { 12 | static bool _initialized = false; 13 | static late final SharedPreferences _prefs; 14 | 15 | static Future init() async { 16 | if (_initialized) { 17 | return; 18 | } 19 | 20 | _initialized = true; 21 | _prefs = await SharedPreferences.getInstance(); 22 | 23 | // Save current version 24 | await _prefs.setInt(_versionKey, _currentVersion); 25 | } 26 | 27 | PreferencesRepository(); 28 | 29 | String get language => _prefs.getString(_languageKey) ?? 'zh'; 30 | 31 | set language(String value) => _prefs.setString(_languageKey, value); 32 | } 33 | -------------------------------------------------------------------------------- /lib/data/repository/profile_repository.dart: -------------------------------------------------------------------------------- 1 | import 'package:injectable/injectable.dart'; 2 | import 'package:masiro/data/repository/masiro_repository.dart'; 3 | import 'package:masiro/data/repository/model/profile.dart'; 4 | import 'package:masiro/di/get_it.dart'; 5 | 6 | @lazySingleton 7 | class ProfileRepository { 8 | final _masiroRepository = getIt(); 9 | 10 | Profile? _cachedProfile; 11 | 12 | bool _needsRefresh = false; 13 | 14 | Future getProfile() async { 15 | if (_needsRefresh) { 16 | return refreshProfile(); 17 | } 18 | if (_cachedProfile != null) { 19 | return _cachedProfile!; 20 | } 21 | _cachedProfile = await _masiroRepository.getProfile(); 22 | return _cachedProfile!; 23 | } 24 | 25 | Future refreshProfile() async { 26 | _cachedProfile = await _masiroRepository.getProfile(); 27 | _needsRefresh = false; 28 | return _cachedProfile!; 29 | } 30 | 31 | void setNeedsRefresh(bool needsRefresh) { 32 | _needsRefresh = needsRefresh; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/di/get_it.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:dio/dio.dart'; 4 | import 'package:dio_cache_interceptor/dio_cache_interceptor.dart'; 5 | import 'package:dio_cache_interceptor_isar_store/dio_cache_interceptor_isar_store.dart'; 6 | import 'package:get_it/get_it.dart'; 7 | import 'package:isar/isar.dart'; 8 | import 'package:logger/logger.dart'; 9 | import 'package:masiro/data/database/core.dart'; 10 | import 'package:masiro/di/injectable.dart'; 11 | import 'package:masiro/misc/cookie.dart'; 12 | import 'package:masiro/misc/url.dart'; 13 | import 'package:path_provider/path_provider.dart'; 14 | 15 | // TODO(qixiao): Configurable user-agent header 16 | const _userAgent = 17 | 'Mozilla/5.0 (X11; Linux x86_64; rv:129.0) Gecko/20100101 Firefox/129.0'; 18 | 19 | final getIt = GetIt.instance; 20 | 21 | Future setupGetIt() async { 22 | // Register logger 23 | final logger = Logger(); 24 | getIt.registerSingleton(logger); 25 | 26 | // Register dio 27 | final supportDir = await getApplicationSupportDirectory(); 28 | final options = CacheOptions( 29 | store: IsarCacheStore(supportDir.path), 30 | policy: CachePolicy.request, 31 | hitCacheOnErrorExcept: const [401, 403], 32 | ); 33 | final dio = Dio(BaseOptions(baseUrl: MasiroUrl.baseUrl)) 34 | ..interceptors.add(DioCacheInterceptor(options: options)) 35 | ..interceptors.add(LogInterceptor(responseBody: false)) 36 | ..options.headers[HttpHeaders.userAgentHeader] = _userAgent 37 | ..options.followRedirects = false 38 | ..options.validateStatus = 39 | (status) => status != null && status >= 200 && status < 400; 40 | getIt.registerSingleton(dio); 41 | 42 | // Register isar 43 | final isar = await Isar.open( 44 | dbSchemas, 45 | directory: supportDir.path, 46 | inspector: true, 47 | ); 48 | getIt.registerSingleton(isar); 49 | 50 | // Config injectable dependencies 51 | configureDependencies(); 52 | 53 | // Config dio cookie manager 54 | await resetCookieManager(); 55 | } 56 | -------------------------------------------------------------------------------- /lib/di/injectable.config.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | // ************************************************************************** 4 | // InjectableConfigGenerator 5 | // ************************************************************************** 6 | 7 | // ignore_for_file: type=lint 8 | // coverage:ignore-file 9 | 10 | // ignore_for_file: no_leading_underscores_for_library_prefixes 11 | import 'package:get_it/get_it.dart' as _i1; 12 | import 'package:injectable/injectable.dart' as _i2; 13 | import 'package:isar/isar.dart' as _i4; 14 | 15 | import '../data/database/dao/app_configuration_dao.dart' as _i3; 16 | import '../data/database/dao/chapter_record_dao.dart' as _i6; 17 | import '../data/database/dao/user_dao.dart' as _i11; 18 | import '../data/repository/app_configuration_repository.dart' as _i5; 19 | import '../data/repository/favorites_repository.dart' as _i7; 20 | import '../data/repository/masiro_repository.dart' as _i8; 21 | import '../data/repository/novel_record_repository.dart' as _i9; 22 | import '../data/repository/profile_repository.dart' as _i10; 23 | import '../data/repository/user_repository.dart' as _i12; 24 | 25 | extension GetItInjectableX on _i1.GetIt { 26 | // initializes the registration of main-scope dependencies inside of GetIt 27 | _i1.GetIt init({ 28 | String? environment, 29 | _i2.EnvironmentFilter? environmentFilter, 30 | }) { 31 | final gh = _i2.GetItHelper( 32 | this, 33 | environment, 34 | environmentFilter, 35 | ); 36 | gh.lazySingleton<_i3.AppConfigurationDao>( 37 | () => _i3.AppConfigurationDao(isar: gh<_i4.Isar>())); 38 | gh.lazySingleton<_i5.AppConfigurationRepository>( 39 | () => _i5.AppConfigurationRepository()); 40 | gh.factory<_i6.ChapterRecordDao>( 41 | () => _i6.ChapterRecordDao(isar: gh<_i4.Isar>())); 42 | gh.lazySingleton<_i7.FavoritesRepository>(() => _i7.FavoritesRepository()); 43 | gh.lazySingleton<_i8.MasiroRepository>(() => _i8.MasiroRepository()); 44 | gh.factory<_i9.NovelRecordRepository>(() => _i9.NovelRecordRepository()); 45 | gh.lazySingleton<_i10.ProfileRepository>(() => _i10.ProfileRepository()); 46 | gh.singleton<_i11.UserDao>(() => _i11.UserDao()); 47 | gh.lazySingleton<_i12.UserRepository>(() => _i12.UserRepository()); 48 | return this; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /lib/di/injectable.dart: -------------------------------------------------------------------------------- 1 | import 'package:injectable/injectable.dart'; 2 | import 'package:masiro/di/get_it.dart'; 3 | import 'package:masiro/di/injectable.config.dart'; 4 | 5 | @InjectableInit( 6 | initializerName: 'init', 7 | preferRelativeImports: true, 8 | asExtension: true, 9 | ) 10 | void configureDependencies() => getIt.init(); 11 | -------------------------------------------------------------------------------- /lib/l10n/app_zh.arb: -------------------------------------------------------------------------------- 1 | { 2 | "home": "首页", 3 | "favorites": "收藏", 4 | "settings": "设置", 5 | "more": "更多", 6 | "showWindow": "显示窗口", 7 | "hideWindow": "隐藏窗口", 8 | "exit": "退出", 9 | "login": "登录", 10 | "mobilePhoneLoginTip": "请在网页中登录,登录成功后点击右上角按钮关闭当前页面", 11 | "desktopLoginTip": "点击下面按钮将打开本地安装的 Chrome 或 Chromium 浏览器,请手动输入帐号密码进行登录,本软件在检测到登录成功后会自动关闭浏览器", 12 | "chromePath": "Chrome 或 Chromium 安装位置:", 13 | "chromeNotFound": "未检测到 Chrome 或 Chromium 浏览器", 14 | "author": "作者", 15 | "translator": "翻译", 16 | "status": "状态", 17 | "originalBook": "原作", 18 | "lastUpdated": "最近更新", 19 | "brief": "简介", 20 | "errorTip": "出错啦~", 21 | "previousPage": "上一页", 22 | "nextPage": "下一页", 23 | "menu": "菜单", 24 | "detail": "详情", 25 | "noContentMessage": "- 空空如也 -", 26 | "noMoreContentTip": "没有更多啦~", 27 | "levelLimitMessage": "等级 {level} 以上用户才能访问", 28 | "levelLimitAndCostMessage": "等级 {level} 以上用户才能访问,将消耗 {cost}G", 29 | "easyRefreshDragText": "下拉刷新", 30 | "easyRefreshArmedText": "松开刷新", 31 | "easyRefreshReadyText": "刷新中...", 32 | "easyRefreshProcessingText": "刷新中...", 33 | "easyRefreshProcessedText": "刷新成功", 34 | "easyRefreshNoMoreText": "没有更多了", 35 | "easyRefreshFailedText": "刷新失败", 36 | "easyRefreshMessageText": "上次刷新时间: %T", 37 | "easyRefreshFooterDragText": "上拉加载更多", 38 | "easyRefreshFooterArmedText": "松开加载更多", 39 | "easyRefreshFooterReadyText": "加载中...", 40 | "easyRefreshFooterProcessingText": "加载中...", 41 | "easyRefreshFooterProcessedText": "加载成功", 42 | "easyRefreshFooterNoMoreText": "没有更多了", 43 | "easyRefreshFooterFailedText": "加载失败", 44 | "easyRefreshFooterMessageText": "上次加载时间: %T", 45 | "startReading": "开始阅读", 46 | "costMessage": "购买当前章节将消耗 {cost}G", 47 | "pay": "支付", 48 | "isPaying": "支付中...", 49 | "coin": "金币", 50 | "fan": "粉丝", 51 | "id": "ID", 52 | "signIn": "签到", 53 | "hasSignedIn": "已签到", 54 | "license": "开源协议", 55 | "close": "关闭", 56 | "cancel": "取消", 57 | "confirm": "确认", 58 | "themeMode": "主题模式", 59 | "lightMode": "日间模式", 60 | "dartMode": "夜间模式", 61 | "systemMode": "跟随系统", 62 | "themeColor": "主题颜色", 63 | "primaryColor": "主色调", 64 | "wheelColor": "自定义", 65 | "reset": "重置", 66 | "collapse": "折叠", 67 | "expand": "展开", 68 | "logout": "登出", 69 | "logoutPrompt": "将清除本地存储的数据,包括但不限于登录信息和历史记录,确定要退出登录吗?", 70 | "about": "关于", 71 | "currentVersion": "当前版本", 72 | "projectHomepage": "项目主页", 73 | "websiteHomepage": "网站主页", 74 | "copied": "已复制", 75 | "fontSize": "字体大小", 76 | "switchAccounts": "切换账号", 77 | "addAnAccount": "添加账号", 78 | "novelComments": "小说评论", 79 | "chapterComments": "章节评论", 80 | "reply": "回复", 81 | "viewMoreReplies": "查看更多回复 ({count})", 82 | "jumpTo": "跳转到" 83 | } -------------------------------------------------------------------------------- /lib/l10n/app_zh_hant_hk.arb: -------------------------------------------------------------------------------- 1 | { 2 | "home": "首页", 3 | "favorites": "收藏", 4 | "settings": "设置", 5 | "more": "更多", 6 | "showWindow": "显示窗口", 7 | "hideWindow": "隐藏窗口", 8 | "exit": "退出", 9 | "login": "登录", 10 | "mobilePhoneLoginTip": "请在网页中登录,登录成功后点击右上角按钮关闭当前页面", 11 | "desktopLoginTip": "点击下面按钮将打开本地安装的 Chrome 或 Chromium 浏览器,请手动输入帐号密码进行登录,本软件在检测到登录成功后会自动关闭浏览器", 12 | "chromePath": "Chrome 或 Chromium 安装位置:", 13 | "chromeNotFound": "未检测到 Chrome 或 Chromium 浏览器", 14 | "author": "作者", 15 | "translator": "翻译", 16 | "status": "状态", 17 | "originalBook": "原作", 18 | "lastUpdated": "最近更新", 19 | "brief": "简介", 20 | "errorTip": "出错啦~", 21 | "previousPage": "上一页", 22 | "nextPage": "下一页", 23 | "menu": "菜单", 24 | "detail": "详情", 25 | "noContentMessage": "- 空空如也 -", 26 | "noMoreContentTip": "没有更多啦~", 27 | "levelLimitMessage": "等级 {level} 以上用户才能访问", 28 | "levelLimitAndCostMessage": "等级 {level} 以上用户才能访问,将消耗 {cost}G", 29 | "easyRefreshDragText": "下拉刷新", 30 | "easyRefreshArmedText": "松开刷新", 31 | "easyRefreshReadyText": "刷新中...", 32 | "easyRefreshProcessingText": "刷新中...", 33 | "easyRefreshProcessedText": "刷新成功", 34 | "easyRefreshNoMoreText": "没有更多了", 35 | "easyRefreshFailedText": "刷新失败", 36 | "easyRefreshMessageText": "上次刷新时间: %T", 37 | "easyRefreshFooterDragText": "上拉加载更多", 38 | "easyRefreshFooterArmedText": "松开加载更多", 39 | "easyRefreshFooterReadyText": "加载中...", 40 | "easyRefreshFooterProcessingText": "加载中...", 41 | "easyRefreshFooterProcessedText": "加载成功", 42 | "easyRefreshFooterNoMoreText": "没有更多了", 43 | "easyRefreshFooterFailedText": "加载失败", 44 | "easyRefreshFooterMessageText": "上次加载时间: %T", 45 | "startReading": "开始阅读", 46 | "costMessage": "购买当前章节将消耗 {cost}G", 47 | "pay": "支付", 48 | "isPaying": "支付中...", 49 | "coin": "金币", 50 | "fan": "粉丝", 51 | "id": "ID", 52 | "signIn": "签到", 53 | "hasSignedIn": "已签到", 54 | "license": "开源协议", 55 | "close": "关闭", 56 | "cancel": "取消", 57 | "confirm": "确认", 58 | "themeMode": "主题模式", 59 | "lightMode": "日间模式", 60 | "dartMode": "夜间模式", 61 | "systemMode": "跟随系统", 62 | "themeColor": "主题颜色", 63 | "primaryColor": "主色调", 64 | "wheelColor": "自定义", 65 | "reset": "重置", 66 | "collapse": "折叠", 67 | "expand": "展开", 68 | "logout": "登出", 69 | "logoutPrompt": "将清除本地存储的数据,包括但不限于登录信息和历史记录,确定要退出登录吗?", 70 | "about": "关于", 71 | "currentVersion": "当前版本", 72 | "projectHomepage": "项目主页", 73 | "websiteHomepage": "网站主页", 74 | "copied": "已复制", 75 | "fontSize": "字体大小", 76 | "switchAccounts": "切换账号", 77 | "addAnAccount": "添加账号", 78 | "novelComments": "小说评论", 79 | "chapterComments": "章节评论", 80 | "reply": "回复", 81 | "viewMoreReplies": "查看更多回复 ({count})", 82 | "jumpTo": "跳转到" 83 | } -------------------------------------------------------------------------------- /lib/l10n/app_zh_hant_tw.arb: -------------------------------------------------------------------------------- 1 | { 2 | "home": "首页", 3 | "favorites": "收藏", 4 | "settings": "设置", 5 | "more": "更多", 6 | "showWindow": "显示窗口", 7 | "hideWindow": "隐藏窗口", 8 | "exit": "退出", 9 | "login": "登录", 10 | "mobilePhoneLoginTip": "请在网页中登录,登录成功后点击右上角按钮关闭当前页面", 11 | "desktopLoginTip": "点击下面按钮将打开本地安装的 Chrome 或 Chromium 浏览器,请手动输入帐号密码进行登录,本软件在检测到登录成功后会自动关闭浏览器", 12 | "chromePath": "Chrome 或 Chromium 安装位置:", 13 | "chromeNotFound": "未检测到 Chrome 或 Chromium 浏览器", 14 | "author": "作者", 15 | "translator": "翻译", 16 | "status": "状态", 17 | "originalBook": "原作", 18 | "lastUpdated": "最近更新", 19 | "brief": "简介", 20 | "errorTip": "出错啦~", 21 | "previousPage": "上一页", 22 | "nextPage": "下一页", 23 | "menu": "菜单", 24 | "detail": "详情", 25 | "noContentMessage": "- 空空如也 -", 26 | "noMoreContentTip": "没有更多啦~", 27 | "levelLimitMessage": "等级 {level} 以上用户才能访问", 28 | "levelLimitAndCostMessage": "等级 {level} 以上用户才能访问,将消耗 {cost}G", 29 | "easyRefreshDragText": "下拉刷新", 30 | "easyRefreshArmedText": "松开刷新", 31 | "easyRefreshReadyText": "刷新中...", 32 | "easyRefreshProcessingText": "刷新中...", 33 | "easyRefreshProcessedText": "刷新成功", 34 | "easyRefreshNoMoreText": "没有更多了", 35 | "easyRefreshFailedText": "刷新失败", 36 | "easyRefreshMessageText": "上次刷新时间: %T", 37 | "easyRefreshFooterDragText": "上拉加载更多", 38 | "easyRefreshFooterArmedText": "松开加载更多", 39 | "easyRefreshFooterReadyText": "加载中...", 40 | "easyRefreshFooterProcessingText": "加载中...", 41 | "easyRefreshFooterProcessedText": "加载成功", 42 | "easyRefreshFooterNoMoreText": "没有更多了", 43 | "easyRefreshFooterFailedText": "加载失败", 44 | "easyRefreshFooterMessageText": "上次加载时间: %T", 45 | "startReading": "开始阅读", 46 | "costMessage": "购买当前章节将消耗 {cost}G", 47 | "pay": "支付", 48 | "isPaying": "支付中...", 49 | "coin": "金币", 50 | "fan": "粉丝", 51 | "id": "ID", 52 | "signIn": "签到", 53 | "hasSignedIn": "已签到", 54 | "license": "开源协议", 55 | "close": "关闭", 56 | "cancel": "取消", 57 | "confirm": "确认", 58 | "themeMode": "主题模式", 59 | "lightMode": "日间模式", 60 | "dartMode": "夜间模式", 61 | "systemMode": "跟随系统", 62 | "themeColor": "主题颜色", 63 | "primaryColor": "主色调", 64 | "wheelColor": "自定义", 65 | "reset": "重置", 66 | "collapse": "折叠", 67 | "expand": "展开", 68 | "logout": "登出", 69 | "logoutPrompt": "将清除本地存储的数据,包括但不限于登录信息和历史记录,确定要退出登录吗?", 70 | "about": "关于", 71 | "currentVersion": "当前版本", 72 | "projectHomepage": "项目主页", 73 | "websiteHomepage": "网站主页", 74 | "copied": "已复制", 75 | "fontSize": "字体大小", 76 | "switchAccounts": "切换账号", 77 | "addAnAccount": "添加账号", 78 | "novelComments": "小说评论", 79 | "chapterComments": "章节评论", 80 | "reply": "回复", 81 | "viewMoreReplies": "查看更多回复 ({count})", 82 | "jumpTo": "跳转到" 83 | } -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:masiro/data/database/migration/migration.dart'; 3 | import 'package:masiro/di/get_it.dart'; 4 | import 'package:masiro/misc/platform.dart'; 5 | import 'package:masiro/ui/app.dart'; 6 | import 'package:window_manager/window_manager.dart'; 7 | 8 | Future main() async { 9 | WidgetsFlutterBinding.ensureInitialized(); 10 | 11 | if (isDesktop) { 12 | await windowManager.ensureInitialized(); 13 | const windowOptions = WindowOptions(title: 'Masiro', center: true); 14 | await windowManager.waitUntilReadyToShow(windowOptions, () async { 15 | await windowManager.show(); 16 | await windowManager.focus(); 17 | }); 18 | } 19 | 20 | await setupGetIt(); 21 | 22 | await performMigrationIfNeeded(); 23 | 24 | runApp(const App()); 25 | } 26 | -------------------------------------------------------------------------------- /lib/misc/chapter.dart: -------------------------------------------------------------------------------- 1 | import 'package:masiro/data/repository/model/volume.dart'; 2 | 3 | Chapter? getChapterFromVolumes(List volumes, int chapterId) { 4 | for (final v in volumes) { 5 | final index = v.chapters.indexWhere((c) => c.id == chapterId); 6 | if (index != -1) { 7 | return v.chapters[index]; 8 | } 9 | } 10 | return null; 11 | } 12 | 13 | Chapter? getPreviousChapter(List volumes, int chapterId) { 14 | for (var vIndex = 0; vIndex < volumes.length; vIndex++) { 15 | final v = volumes[vIndex]; 16 | final cIndex = v.chapters.indexWhere((c) => c.id == chapterId); 17 | if (cIndex != -1) { 18 | if (cIndex == 0) { 19 | final prevVolumeIndex = vIndex - 1; 20 | if (prevVolumeIndex < 0) { 21 | return null; 22 | } 23 | final prevVolume = volumes[prevVolumeIndex]; 24 | return prevVolume.chapters.lastOrNull; 25 | } else { 26 | return v.chapters[cIndex - 1]; 27 | } 28 | } 29 | } 30 | return null; 31 | } 32 | 33 | Chapter? getNextChapter(List volumes, int chapterId) { 34 | for (var vIndex = 0; vIndex < volumes.length; vIndex++) { 35 | final v = volumes[vIndex]; 36 | final cIndex = v.chapters.indexWhere((c) => c.id == chapterId); 37 | if (cIndex != -1) { 38 | if (cIndex == v.chapters.length - 1) { 39 | final nextVolumeIndex = vIndex + 1; 40 | if (nextVolumeIndex >= volumes.length) { 41 | return null; 42 | } 43 | final nextVolume = volumes[nextVolumeIndex]; 44 | return nextVolume.chapters.firstOrNull; 45 | } else { 46 | return v.chapters[cIndex + 1]; 47 | } 48 | } 49 | } 50 | return null; 51 | } 52 | -------------------------------------------------------------------------------- /lib/misc/chrome.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:flutter/foundation.dart'; 4 | 5 | Future findChrome() async { 6 | if (Platform.isWindows) { 7 | return findChromeOnWindows(); 8 | } else if (Platform.isMacOS) { 9 | return findChromeOnMacOS(); 10 | } else if (Platform.isLinux) { 11 | return findChromeOnLinux(); 12 | } 13 | return null; 14 | } 15 | 16 | Future findChromeOnWindows() async { 17 | try { 18 | final result = await Process.run( 19 | 'powershell', 20 | [ 21 | '-NoProfile', 22 | '-Command', 23 | r'Get-ItemProperty -Path Registry::"HKLM\Software\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe" | Select-Object -ExpandProperty Path', 24 | ], 25 | runInShell: true, 26 | ); 27 | 28 | if (result.exitCode == 0) { 29 | final path = result.stdout.trim(); 30 | if (path.isNotEmpty) { 31 | return path; 32 | } 33 | } 34 | } catch (e) {} 35 | 36 | return null; 37 | } 38 | 39 | Future findChromeOnMacOS() async { 40 | try { 41 | final result = await Process.run( 42 | 'mdfind', 43 | ['kMDItemCFBundleIdentifier=="com.google.Chrome"'], 44 | ); 45 | 46 | if (result.exitCode == 0) { 47 | final paths = result.stdout.trim().split('\n'); 48 | if (paths.isNotEmpty) { 49 | return '${paths.first}/Contents/MacOS/Google Chrome'; 50 | } 51 | } 52 | } catch (e) {} 53 | 54 | return null; 55 | } 56 | 57 | Future findChromeOnLinux() async { 58 | // Use `which` command to find `google-chrome` or `chromium` 59 | final chromePath = await findExecutablePath('google-chrome'); 60 | if (chromePath != null) { 61 | return chromePath; 62 | } 63 | 64 | final chromiumPath = await findExecutablePath('chromium'); 65 | if (chromiumPath != null) { 66 | return chromiumPath; 67 | } 68 | 69 | final chromiumBrowserPath = await findExecutablePath('chromium-browser'); 70 | if (chromiumBrowserPath != null) { 71 | return chromiumBrowserPath; 72 | } 73 | 74 | return null; 75 | } 76 | 77 | Future findExecutablePath(String executableName) async { 78 | try { 79 | final result = await Process.run('which', [executableName]); 80 | if (result.exitCode == 0) { 81 | return result.stdout.trim(); 82 | } 83 | } catch (e) { 84 | debugPrint('The `which` command is unavailable.'); 85 | } 86 | return null; 87 | } 88 | -------------------------------------------------------------------------------- /lib/misc/constant.dart: -------------------------------------------------------------------------------- 1 | const coverRatio = 7 / 10; 2 | 3 | const defaultThemeColor = 0xFF6750A4; 4 | 5 | const projectHomepage = 'https://github.com/qixiaoo/masiro'; 6 | 7 | const defaultFontSize = 14; 8 | 9 | const defaultUserId = -1; 10 | -------------------------------------------------------------------------------- /lib/misc/context.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 3 | 4 | extension ContextUtils on BuildContext { 5 | AppLocalizations localizations() { 6 | return AppLocalizations.of(this)!; 7 | } 8 | 9 | ThemeData theme() { 10 | return Theme.of(this); 11 | } 12 | 13 | ColorScheme colorScheme() { 14 | return Theme.of(this).colorScheme; 15 | } 16 | 17 | TextTheme textTheme() { 18 | return Theme.of(this).textTheme; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /lib/misc/cookie_storage.dart: -------------------------------------------------------------------------------- 1 | import 'package:cookie_jar/cookie_jar.dart'; 2 | 3 | typedef CookieWriter = Future Function(String key, String? value); 4 | typedef CookieReader = Future Function(String key); 5 | 6 | class CookieStorage implements Storage { 7 | CookieWriter cookieWriter; 8 | CookieReader cookieReader; 9 | 10 | CookieStorage({ 11 | required this.cookieWriter, 12 | required this.cookieReader, 13 | }); 14 | 15 | @override 16 | Future init(bool persistSession, bool ignoreExpires) async {} 17 | 18 | @override 19 | Future delete(String key) async { 20 | return cookieWriter(key, null); 21 | } 22 | 23 | @override 24 | Future deleteAll(List keys) async { 25 | for (var i = 0; i < keys.length; i++) { 26 | await delete(keys[i]); 27 | } 28 | } 29 | 30 | @override 31 | Future read(String key) async { 32 | return cookieReader(key); 33 | } 34 | 35 | @override 36 | Future write(String key, String value) async { 37 | return cookieWriter(key, value); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /lib/misc/easy_refresh.dart: -------------------------------------------------------------------------------- 1 | import 'package:easy_refresh/easy_refresh.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:masiro/misc/context.dart'; 4 | 5 | ClassicHeader classicHeader(BuildContext context) { 6 | final localizations = context.localizations(); 7 | 8 | final dragText = localizations.easyRefreshDragText; 9 | final armedText = localizations.easyRefreshArmedText; 10 | final readyText = localizations.easyRefreshReadyText; 11 | final processingText = localizations.easyRefreshProcessingText; 12 | final processedText = localizations.easyRefreshProcessedText; 13 | final noMoreText = localizations.easyRefreshNoMoreText; 14 | final failedText = localizations.easyRefreshFailedText; 15 | final messageText = localizations.easyRefreshMessageText; 16 | 17 | return ClassicHeader( 18 | dragText: dragText, 19 | armedText: armedText, 20 | readyText: readyText, 21 | processingText: processingText, 22 | processedText: processedText, 23 | noMoreText: noMoreText, 24 | failedText: failedText, 25 | messageText: messageText, 26 | ); 27 | } 28 | 29 | ClassicFooter classicFooter(BuildContext context) { 30 | final localizations = context.localizations(); 31 | 32 | final dragText = localizations.easyRefreshFooterDragText; 33 | final armedText = localizations.easyRefreshFooterArmedText; 34 | final readyText = localizations.easyRefreshFooterReadyText; 35 | final processingText = localizations.easyRefreshFooterProcessingText; 36 | final processedText = localizations.easyRefreshFooterProcessedText; 37 | final noMoreText = localizations.easyRefreshFooterNoMoreText; 38 | final failedText = localizations.easyRefreshFooterFailedText; 39 | final messageText = localizations.easyRefreshFooterMessageText; 40 | 41 | return ClassicFooter( 42 | dragText: dragText, 43 | armedText: armedText, 44 | readyText: readyText, 45 | processingText: processingText, 46 | processedText: processedText, 47 | noMoreText: noMoreText, 48 | failedText: failedText, 49 | messageText: messageText, 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /lib/misc/helper.dart: -------------------------------------------------------------------------------- 1 | /// Returns a function which caches the result of the execution of [fn]. 2 | /// The returned function ensures that [fn] is executed only during the first call. 3 | /// For all subsequent calls to the returned function, it returns the cached result directly. 4 | ReturnType Function() onceFn(ReturnType Function() fn) { 5 | dynamic result; 6 | bool isFirstTimeRun = true; 7 | 8 | return () { 9 | if (!isFirstTimeRun) { 10 | return result; 11 | } 12 | result = fn(); 13 | isFirstTimeRun = false; 14 | return result; 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /lib/misc/list.dart: -------------------------------------------------------------------------------- 1 | List indexedMap( 2 | List list, 3 | U Function(int index, T element) mapper, 4 | ) { 5 | final result = []; 6 | for (var i = 0; i < list.length; i++) { 7 | result.add(mapper(i, list[i])); 8 | } 9 | return result; 10 | } 11 | -------------------------------------------------------------------------------- /lib/misc/platform.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | bool isDesktop = Platform.isMacOS || Platform.isLinux || Platform.isWindows; 4 | bool isMobilePhone = Platform.isAndroid || Platform.isIOS; 5 | -------------------------------------------------------------------------------- /lib/misc/pubspec.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/services.dart' show rootBundle; 2 | 3 | Future getVersion() async { 4 | final contents = await rootBundle.loadString('pubspec.yaml'); 5 | final lines = contents.split('\n'); 6 | 7 | for (final line in lines) { 8 | if (line.startsWith('version:')) { 9 | final version = line.split(':')[1].trim(); 10 | return version; 11 | } 12 | } 13 | 14 | return null; 15 | } 16 | -------------------------------------------------------------------------------- /lib/misc/render.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:masiro/data/repository/model/chapter_detail.dart'; 3 | 4 | int getFirstVisibleCharacterIndex( 5 | String text, 6 | TextStyle? style, 7 | double width, 8 | double topOffset, 9 | ) { 10 | final textPainter = TextPainter( 11 | text: TextSpan(text: text, style: style), 12 | textDirection: TextDirection.ltr, 13 | ); 14 | textPainter.layout(minWidth: 20, maxWidth: width); 15 | final textPosition = textPainter.getPositionForOffset(Offset(0, topOffset)); 16 | return textPosition.offset; 17 | } 18 | 19 | int getArticleCharacterIndex( 20 | List elements, 21 | int elementIndex, 22 | int elementCharacterIndex, 23 | ) { 24 | var position = 0; 25 | for (var i = 0; i < elementIndex; i++) { 26 | final e = elements[i]; 27 | if (e is TextContent) { 28 | position = position + e.text.length; 29 | } 30 | } 31 | return position + elementCharacterIndex; 32 | } 33 | 34 | double getTextElementTopOffset( 35 | String text, 36 | TextStyle? style, 37 | double width, 38 | int characterIndex, 39 | ) { 40 | if (characterIndex <= 0) { 41 | return 0.0; 42 | } 43 | var invisibleText = text.substring(0, characterIndex); 44 | final lastCharacter = invisibleText[invisibleText.length - 1]; 45 | if (lastCharacter == '\n') { 46 | invisibleText = text.substring(0, characterIndex - 1); 47 | } 48 | final textPainter = TextPainter( 49 | text: TextSpan(text: invisibleText, style: style), 50 | textDirection: TextDirection.ltr, 51 | ); 52 | textPainter.layout(minWidth: 20, maxWidth: width); 53 | return -textPainter.height; 54 | } 55 | -------------------------------------------------------------------------------- /lib/misc/time.dart: -------------------------------------------------------------------------------- 1 | bool areTimestampsOnSameDay(int timestamp1, int timestamp2) { 2 | final dateTime1 = DateTime.fromMillisecondsSinceEpoch(timestamp1); 3 | final dateTime2 = DateTime.fromMillisecondsSinceEpoch(timestamp2); 4 | 5 | return dateTime1.year == dateTime2.year && 6 | dateTime1.month == dateTime2.month && 7 | dateTime1.day == dateTime2.day; 8 | } 9 | 10 | bool isTimestampToday(int timestamp) { 11 | return areTimestampsOnSameDay( 12 | timestamp, 13 | DateTime.now().millisecondsSinceEpoch, 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /lib/misc/toast.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; 2 | 3 | extension StringToast on String { 4 | void toast() { 5 | SmartDialog.showToast(this); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /lib/misc/tray_icon.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:masiro/misc/context.dart'; 3 | import 'package:tray_manager/tray_manager.dart'; 4 | 5 | class MenuItemKey { 6 | static const String show = 'show_window'; 7 | static const String hide = 'hide_window'; 8 | static const String exit = 'exit_app'; 9 | } 10 | 11 | Future initSystemTray(BuildContext context) async { 12 | final localizations = context.localizations(); 13 | 14 | await trayManager.setIcon( 15 | 'assets/icon/icon.png', 16 | ); 17 | 18 | final Menu menu = Menu( 19 | items: [ 20 | MenuItem( 21 | key: MenuItemKey.show, 22 | label: localizations.showWindow, 23 | ), 24 | MenuItem( 25 | key: MenuItemKey.hide, 26 | label: localizations.hideWindow, 27 | ), 28 | MenuItem.separator(), 29 | MenuItem( 30 | key: MenuItemKey.exit, 31 | label: localizations.exit, 32 | ), 33 | ], 34 | ); 35 | 36 | await trayManager.setContextMenu(menu); 37 | } 38 | -------------------------------------------------------------------------------- /lib/misc/url.dart: -------------------------------------------------------------------------------- 1 | class MasiroUrl { 2 | static const baseUrl = 'https://masiro.me/'; 3 | static const baseUrlWithoutSlash = 'https://masiro.me'; 4 | 5 | static const defaultAvatar = '/masiroImg/header-default.jpg'; 6 | 7 | // Pages 8 | static const adminUrl = 'https://masiro.me/admin'; // Home page 9 | static const loginUrl = 'https://masiro.me/admin/auth/login'; // Login page 10 | static const collectionUrl = 11 | 'https://masiro.me/admin/novels?collection=1'; // My collection page 12 | static const novelViewUrl = 13 | 'https://masiro.me/admin/novelView'; // Novel detail page 14 | static const novelReadingUrl = 15 | 'https://masiro.me/admin/novelReading'; // Novel reading page 16 | static const novelsUrl = 'https://masiro.me/admin/novels'; // All novels page 17 | static const novelRankUrl = 18 | 'https://masiro.me/admin/novelRank'; // Novel rank page 19 | 20 | // APIs 21 | static const recentUpdates = 'admin/recentUpdates'; 22 | static const loadMoreNovels = 'admin/loadMoreNovels'; 23 | static const getChapterComments = 'admin/getChapterComments'; 24 | static const getNovelComments = 'admin/getNovelComments'; 25 | static const collectNovel = 'admin/collectNovel'; 26 | static const uncollectNovel = 'admin/uncollectNovel'; 27 | static const pay = 'admin/pay'; 28 | static const dailySignIn = 'admin/dailySignIn'; 29 | static const logout = 'admin/auth/logout'; 30 | } 31 | 32 | extension ImageUrl on String { 33 | String toUrl() { 34 | return '${MasiroUrl.baseUrlWithoutSlash}$this'; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /lib/ui/screens/comments/comment_card_header.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:intl/intl.dart'; 3 | import 'package:masiro/data/repository/model/comment.dart'; 4 | import 'package:masiro/misc/context.dart'; 5 | 6 | class CommentCardHeader extends StatelessWidget { 7 | final Comment comment; 8 | final Comment? parentComment; 9 | final int? replyIndex; 10 | 11 | const CommentCardHeader({ 12 | super.key, 13 | required this.comment, 14 | this.parentComment, 15 | this.replyIndex, 16 | }); 17 | 18 | @override 19 | Widget build(BuildContext context) { 20 | final localizations = context.localizations(); 21 | 22 | final floor = comment.floor; 23 | final parentFloor = parentComment?.floor; 24 | final isReply = parentComment != null; 25 | 26 | final floorIndicator = 27 | !isReply ? Text('#$floor') : Text('#$parentFloor-${replyIndex! + 1}'); 28 | 29 | Text? replyTarget; 30 | if (isReply) { 31 | final replies = parentComment!.replies; 32 | final targetIndex = replies.indexWhere((r) => r.id == comment.parentId); 33 | if (targetIndex != -1) { 34 | replyTarget = Text( 35 | '${localizations.reply} #$parentFloor-${targetIndex + 1}' 36 | ' @${replies[targetIndex].name}', 37 | ); 38 | } 39 | } 40 | 41 | return Row( 42 | children: [ 43 | Expanded( 44 | child: Column( 45 | crossAxisAlignment: CrossAxisAlignment.start, 46 | children: [ 47 | Text( 48 | comment.name, 49 | maxLines: 1, 50 | overflow: TextOverflow.ellipsis, 51 | ), 52 | Text(DateFormat.yMd().format(comment.createdAt)), 53 | if (replyTarget != null) replyTarget, 54 | ], 55 | ), 56 | ), 57 | floorIndicator, 58 | ], 59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /lib/ui/screens/comments/comment_card_reply_list.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:masiro/data/repository/model/comment.dart'; 3 | import 'package:masiro/misc/context.dart'; 4 | import 'package:masiro/misc/list.dart'; 5 | import 'package:masiro/ui/screens/comments/comment_card.dart'; 6 | 7 | const _defaultSize = 3; 8 | 9 | class CommentCardReplyList extends StatefulWidget { 10 | final Comment comment; 11 | final List replies; 12 | 13 | const CommentCardReplyList({ 14 | super.key, 15 | required this.comment, 16 | required this.replies, 17 | }); 18 | 19 | @override 20 | State createState() => _CommentCardReplyListState(); 21 | } 22 | 23 | class _CommentCardReplyListState extends State { 24 | late bool isExpanded; 25 | 26 | @override 27 | void initState() { 28 | super.initState(); 29 | isExpanded = widget.replies.length <= _defaultSize; 30 | } 31 | 32 | @override 33 | Widget build(BuildContext context) { 34 | final colorScheme = context.colorScheme(); 35 | final localizations = context.localizations(); 36 | 37 | final comment = widget.comment; 38 | final replies = widget.replies; 39 | final size = replies.length <= _defaultSize ? replies.length : _defaultSize; 40 | final truncatedList = isExpanded ? replies : replies.sublist(0, size); 41 | 42 | return ClipRRect( 43 | borderRadius: BorderRadius.circular(12.0), 44 | child: Container( 45 | padding: const EdgeInsets.all(10), 46 | color: colorScheme.surfaceContainer, 47 | child: Column( 48 | crossAxisAlignment: CrossAxisAlignment.start, 49 | children: [ 50 | ...indexedMap(truncatedList, (index, reply) { 51 | return CommentCard( 52 | comment: reply, 53 | parentComment: comment, 54 | replyIndex: index, 55 | ); 56 | }), 57 | if (!isExpanded) 58 | Align( 59 | alignment: Alignment.centerRight, 60 | child: TextButton( 61 | onPressed: () => setState(() => isExpanded = !isExpanded), 62 | child: Text( 63 | localizations.viewMoreReplies(replies.length - size), 64 | ), 65 | ), 66 | ), 67 | ], 68 | ), 69 | ), 70 | ); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /lib/ui/screens/comments/pagination_dialog.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:masiro/misc/context.dart'; 3 | 4 | class PaginationDialog extends StatelessWidget { 5 | final int totalPages; 6 | final int currentPage; 7 | final void Function(int page) onPageChanged; 8 | 9 | const PaginationDialog({ 10 | super.key, 11 | required this.totalPages, 12 | required this.currentPage, 13 | required this.onPageChanged, 14 | }); 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | final localizations = context.localizations(); 19 | final pageList = List.generate(totalPages, (i) => i + 1); 20 | 21 | return AlertDialog( 22 | title: Text(localizations.jumpTo), 23 | content: SingleChildScrollView( 24 | child: Column( 25 | mainAxisSize: MainAxisSize.min, 26 | children: [ 27 | ...pageList.map( 28 | (page) => ListTile( 29 | title: Text(page.toString()), 30 | leading: Radio( 31 | value: currentPage, 32 | groupValue: page, 33 | onChanged: (_) => onPageChanged(page), 34 | ), 35 | onTap: () => onPageChanged(page), 36 | ), 37 | ), 38 | ], 39 | ), 40 | ), 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/ui/screens/error/error_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:go_router/go_router.dart'; 3 | import 'package:masiro/ui/widgets/error_message.dart'; 4 | 5 | class ErrorScreen extends StatelessWidget { 6 | final String? message; 7 | 8 | const ErrorScreen({super.key, required this.message}); 9 | 10 | @override 11 | Widget build(BuildContext context) { 12 | return Scaffold( 13 | appBar: AppBar( 14 | leading: IconButton( 15 | onPressed: () => context.pop(), 16 | icon: const Icon(Icons.arrow_back_rounded), 17 | ), 18 | ), 19 | body: ErrorMessage( 20 | message: message, 21 | ), 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/ui/screens/home/home_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:go_router/go_router.dart'; 3 | import 'package:masiro/misc/context.dart'; 4 | import 'package:masiro/misc/router.dart'; 5 | import 'package:masiro/ui/widgets/message.dart'; 6 | 7 | class HomeScreen extends StatefulWidget { 8 | const HomeScreen({super.key}); 9 | 10 | @override 11 | State createState() => _HomeScreenState(); 12 | } 13 | 14 | class _HomeScreenState extends State { 15 | @override 16 | Widget build(BuildContext context) { 17 | final localizations = context.localizations(); 18 | return Column( 19 | children: [ 20 | AppBar( 21 | actions: [ 22 | IconButton( 23 | onPressed: () => context.push(RoutePath.search), 24 | icon: const Icon(Icons.search_rounded), 25 | ), 26 | ], 27 | ), 28 | Expanded( 29 | child: Message(message: localizations.noContentMessage), 30 | ), 31 | ], 32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/ui/screens/license/license_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:go_router/go_router.dart'; 3 | 4 | class LicenseScreen extends StatelessWidget { 5 | final String name; 6 | final String? license; 7 | 8 | const LicenseScreen({ 9 | super.key, 10 | required this.name, 11 | required this.license, 12 | }); 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | return Scaffold( 17 | appBar: AppBar( 18 | leading: IconButton( 19 | onPressed: () => context.pop(), 20 | icon: const Icon(Icons.arrow_back_rounded), 21 | ), 22 | title: Text(name), 23 | ), 24 | body: ListView( 25 | padding: const EdgeInsets.all(20), 26 | children: [ 27 | Text(license ?? ''), 28 | ], 29 | ), 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/ui/screens/licenses/licenses_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:go_router/go_router.dart'; 3 | import 'package:masiro/misc/context.dart'; 4 | import 'package:masiro/misc/oss_licenses.dart'; 5 | import 'package:masiro/misc/router.dart'; 6 | 7 | class LicensesScreen extends StatefulWidget { 8 | const LicensesScreen({super.key}); 9 | 10 | @override 11 | State createState() => _LicensesScreenState(); 12 | } 13 | 14 | class _LicensesScreenState extends State { 15 | @override 16 | Widget build(BuildContext context) { 17 | final localizations = context.localizations(); 18 | 19 | return Scaffold( 20 | appBar: AppBar( 21 | leading: IconButton( 22 | onPressed: () => context.pop(), 23 | icon: const Icon(Icons.arrow_back_rounded), 24 | ), 25 | title: Text(localizations.license), 26 | ), 27 | body: ListView.builder( 28 | itemCount: dependencies.length, 29 | itemBuilder: (context, index) { 30 | final package = dependencies[index]; 31 | return ListTile( 32 | title: Text(package.name), 33 | onTap: () => context.push( 34 | RoutePath.license, 35 | extra: { 36 | 'name': package.name, 37 | 'license': package.license, 38 | }, 39 | ), 40 | ); 41 | }, 42 | ), 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/ui/screens/login/login_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:masiro/misc/platform.dart'; 3 | import 'package:masiro/ui/screens/login/desktop_login.dart'; 4 | import 'package:masiro/ui/screens/login/mobile_phone_login.dart'; 5 | 6 | class LoginScreen extends StatelessWidget { 7 | const LoginScreen({super.key}); 8 | 9 | @override 10 | Widget build(BuildContext context) { 11 | return isDesktop ? const DesktopLogin() : const MobilePhoneLogin(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /lib/ui/screens/novel/expandable_brief.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:masiro/misc/context.dart'; 3 | import 'package:masiro/ui/widgets/after_layout.dart'; 4 | 5 | const _maskHeight = 40.0; 6 | const _defaultMaxHeight = 200.0; 7 | 8 | class ExpandableBrief extends StatefulWidget { 9 | final String brief; 10 | 11 | const ExpandableBrief({super.key, required this.brief}); 12 | 13 | @override 14 | State createState() => _ExpandableBriefState(); 15 | } 16 | 17 | class _ExpandableBriefState extends State { 18 | bool isExpanded = false; 19 | bool isExpandable = false; 20 | late double intrinsicHeight; 21 | 22 | @override 23 | Widget build(BuildContext context) { 24 | final textTheme = context.textTheme(); 25 | final localizations = context.localizations(); 26 | final surfaceColor = context.colorScheme().surface; 27 | 28 | final boxDecoration = BoxDecoration( 29 | gradient: LinearGradient( 30 | colors: [surfaceColor.withOpacity(0.5), surfaceColor.withOpacity(1.0)], 31 | begin: Alignment.topCenter, 32 | end: Alignment.bottomCenter, 33 | ), 34 | ); 35 | 36 | return Stack( 37 | children: [ 38 | AnimatedContainer( 39 | constraints: BoxConstraints( 40 | maxHeight: isExpanded 41 | ? intrinsicHeight + _maskHeight 42 | : _defaultMaxHeight + _maskHeight, 43 | ), 44 | height: isExpanded ? intrinsicHeight + _maskHeight : null, 45 | duration: const Duration(milliseconds: 300), 46 | curve: Curves.fastOutSlowIn, 47 | clipBehavior: Clip.hardEdge, 48 | decoration: const BoxDecoration(), 49 | child: AfterLayout( 50 | callback: (value) { 51 | final maxHeight = value.getMaxIntrinsicHeight(value.size.width); 52 | setState(() { 53 | intrinsicHeight = maxHeight; 54 | isExpandable = maxHeight > _defaultMaxHeight; 55 | }); 56 | }, 57 | child: SelectionArea( 58 | child: Text( 59 | widget.brief, 60 | softWrap: true, 61 | style: textTheme.bodyLarge, 62 | ), 63 | ), 64 | ), 65 | ), 66 | if (isExpandable) 67 | Positioned( 68 | left: 0, 69 | right: 0, 70 | bottom: 0, 71 | child: Container( 72 | height: _maskHeight, 73 | decoration: boxDecoration, 74 | alignment: Alignment.center, 75 | child: TextButton( 76 | onPressed: () => setState(() => isExpanded = !isExpanded), 77 | child: Text( 78 | isExpanded ? localizations.collapse : localizations.expand, 79 | ), 80 | ), 81 | ), 82 | ), 83 | ], 84 | ); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /lib/ui/screens/novel/novel_header.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:masiro/data/repository/model/novel_detail.dart'; 3 | import 'package:masiro/misc/constant.dart'; 4 | import 'package:masiro/misc/context.dart'; 5 | import 'package:masiro/misc/platform.dart'; 6 | import 'package:masiro/misc/url.dart'; 7 | import 'package:masiro/ui/widgets/cached_image.dart'; 8 | 9 | class NovelHeader extends StatelessWidget { 10 | final NovelDetailHeader header; 11 | 12 | const NovelHeader({super.key, required this.header}); 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | final localizations = context.localizations(); 17 | final textTheme = context.textTheme(); 18 | const coverWidth = 160.0; 19 | 20 | return Row( 21 | children: [ 22 | CachedImage( 23 | url: header.coverImg.toUrl(), 24 | width: coverWidth, 25 | height: coverWidth / coverRatio, 26 | fit: BoxFit.cover, 27 | ), 28 | const SizedBox(width: 20), 29 | Expanded( 30 | child: SelectionArea( 31 | child: Column( 32 | crossAxisAlignment: CrossAxisAlignment.start, 33 | children: [ 34 | Padding( 35 | padding: const EdgeInsets.only(bottom: 10), 36 | child: Text( 37 | header.title, 38 | style: textTheme.titleLarge, 39 | ), 40 | ), 41 | Text( 42 | '${localizations.author}: ${header.author}', 43 | style: textTheme.bodyLarge, 44 | ), 45 | Text( 46 | '${localizations.translator}: ${header.translators.join(', ')}', 47 | style: textTheme.bodyLarge, 48 | ), 49 | Text( 50 | '${localizations.status}: ${header.status}', 51 | style: textTheme.bodyLarge, 52 | ), 53 | if (isDesktop) 54 | Text( 55 | '${localizations.originalBook}: ${header.originalBook}', 56 | style: textTheme.bodyLarge, 57 | ), 58 | ], 59 | ), 60 | ), 61 | ), 62 | ], 63 | ); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /lib/ui/screens/reader/bottom_bar.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:masiro/misc/context.dart'; 3 | 4 | const Duration _duration = Duration(milliseconds: 500); 5 | const Curve _curve = Curves.fastOutSlowIn; 6 | 7 | const bottomBarHeight = kToolbarHeight; 8 | 9 | class BottomBar extends StatelessWidget { 10 | final bool isVisible; 11 | final int? prevChapterId; 12 | final int? nextChapterId; 13 | 14 | final void Function(int chapterId) onNavigateTo; 15 | final void Function() onSettingsClicked; 16 | final void Function() onCommentClicked; 17 | 18 | const BottomBar({ 19 | super.key, 20 | required this.isVisible, 21 | this.prevChapterId, 22 | this.nextChapterId, 23 | required this.onNavigateTo, 24 | required this.onSettingsClicked, 25 | required this.onCommentClicked, 26 | }); 27 | 28 | @override 29 | Widget build(BuildContext context) { 30 | const horizontalPadding = 8.0; 31 | final bottomPadding = MediaQuery.of(context).padding.bottom; 32 | final theme = context.theme(); 33 | final appBarThemeColor = theme.appBarTheme.backgroundColor; 34 | final surfaceContainerColor = theme.colorScheme.surfaceContainer; 35 | final backgroundColor = appBarThemeColor ?? surfaceContainerColor; 36 | 37 | return AnimatedPositioned( 38 | left: 0, 39 | right: 0, 40 | bottom: isVisible ? 0.0 : -(bottomBarHeight + bottomPadding), 41 | duration: _duration, 42 | curve: _curve, 43 | child: Container( 44 | color: backgroundColor, 45 | height: bottomBarHeight, 46 | padding: EdgeInsets.only( 47 | bottom: bottomPadding, 48 | left: horizontalPadding, 49 | right: horizontalPadding, 50 | ), 51 | child: Row( 52 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 53 | children: [ 54 | IconButton( 55 | onPressed: prevChapterId == null 56 | ? null 57 | : () => onNavigateTo(prevChapterId!), 58 | icon: const Icon(Icons.skip_previous_rounded), 59 | ), 60 | IconButton( 61 | onPressed: onSettingsClicked, 62 | icon: const Icon(Icons.settings_rounded), 63 | ), 64 | IconButton( 65 | onPressed: onCommentClicked, 66 | icon: const Icon(Icons.comment), 67 | ), 68 | IconButton( 69 | onPressed: nextChapterId == null 70 | ? null 71 | : () => onNavigateTo(nextChapterId!), 72 | icon: const Icon(Icons.skip_next_rounded), 73 | ), 74 | ], 75 | ), 76 | ), 77 | ); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /lib/ui/screens/reader/payment_detail.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | import 'package:masiro/bloc/screen/reader/reader_screen_bloc.dart'; 4 | import 'package:masiro/data/repository/model/chapter_detail.dart'; 5 | import 'package:masiro/misc/context.dart'; 6 | import 'package:masiro/misc/toast.dart'; 7 | 8 | class PaymentDetail extends StatefulWidget { 9 | final PaymentInfo paymentInfo; 10 | 11 | const PaymentDetail({super.key, required this.paymentInfo}); 12 | 13 | @override 14 | State createState() => _PaymentDetailState(); 15 | } 16 | 17 | class _PaymentDetailState extends State { 18 | bool isPaying = false; 19 | 20 | @override 21 | Widget build(BuildContext context) { 22 | final localizations = context.localizations(); 23 | final cost = widget.paymentInfo.cost; 24 | 25 | final onPressed = !isPaying ? () => _onPaymentButtonPressed(context) : null; 26 | 27 | return Center( 28 | child: Column( 29 | mainAxisAlignment: MainAxisAlignment.center, 30 | children: [ 31 | Text(localizations.costMessage(cost)), 32 | const SizedBox(height: 10), 33 | TextButton( 34 | onPressed: onPressed, 35 | child: Text(!isPaying ? localizations.pay : localizations.isPaying), 36 | ), 37 | ], 38 | ), 39 | ); 40 | } 41 | 42 | Future _onPaymentButtonPressed(BuildContext context) async { 43 | setState(() => isPaying = true); 44 | final bloc = context.read(); 45 | try { 46 | final result = await bloc.purchasePaidChapter(widget.paymentInfo); 47 | result.toast(); 48 | } catch (e) { 49 | e.toString().toast(); 50 | } finally { 51 | setState(() => isPaying = false); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /lib/ui/screens/reader/pointer_area_indicator.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:masiro/data/repository/model/reading_mode.dart'; 3 | import 'package:masiro/misc/context.dart'; 4 | 5 | class PointerAreaIndicator extends StatelessWidget { 6 | final ReadingMode readingMode; 7 | 8 | const PointerAreaIndicator({super.key, required this.readingMode}); 9 | 10 | @override 11 | Widget build(BuildContext context) { 12 | final localizations = context.localizations(); 13 | 14 | final previous = Expanded( 15 | child: ColoredBox( 16 | color: Colors.greenAccent.withOpacity(0.5), 17 | child: Center( 18 | child: Text(localizations.previousPage), 19 | ), 20 | ), 21 | ); 22 | 23 | final next = Expanded( 24 | child: ColoredBox( 25 | color: Colors.orangeAccent.withOpacity(0.5), 26 | child: Center( 27 | child: Text(localizations.nextPage), 28 | ), 29 | ), 30 | ); 31 | 32 | return IgnorePointer( 33 | child: Row( 34 | crossAxisAlignment: CrossAxisAlignment.stretch, 35 | children: [ 36 | if (readingMode.isPage()) previous, 37 | Expanded( 38 | child: ColoredBox( 39 | color: Colors.blueAccent.withOpacity(0.5), 40 | child: Center( 41 | child: Text(localizations.menu), 42 | ), 43 | ), 44 | ), 45 | if (readingMode.isPage()) next, 46 | ], 47 | ), 48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /lib/ui/screens/reader/settings_sheet.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:masiro/misc/context.dart'; 3 | 4 | class SettingsSheet extends StatefulWidget { 5 | final int fontSize; 6 | final void Function(int fontSize) onFontSizeChanged; 7 | 8 | const SettingsSheet({ 9 | super.key, 10 | required this.fontSize, 11 | required this.onFontSizeChanged, 12 | }); 13 | 14 | @override 15 | State createState() => _SettingsSheetState(); 16 | } 17 | 18 | class _SettingsSheetState extends State { 19 | late int fontSize; 20 | 21 | @override 22 | void initState() { 23 | super.initState(); 24 | fontSize = widget.fontSize; 25 | } 26 | 27 | @override 28 | Widget build(BuildContext context) { 29 | final localizations = context.localizations(); 30 | 31 | return Container( 32 | width: double.infinity, 33 | padding: const EdgeInsets.only(left: 20, right: 20, bottom: 20), 34 | child: Column( 35 | crossAxisAlignment: CrossAxisAlignment.start, 36 | children: [ 37 | Row( 38 | children: [ 39 | Text(localizations.fontSize), 40 | Expanded( 41 | child: Slider( 42 | value: fontSize.toDouble(), 43 | min: 12, 44 | max: 32, 45 | divisions: 20, 46 | label: fontSize.toString(), 47 | onChanged: (value) { 48 | setState(() => fontSize = value.toInt()); 49 | widget.onFontSizeChanged(value.toInt()); 50 | }, 51 | ), 52 | ), 53 | ], 54 | ), 55 | ], 56 | ), 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /lib/ui/screens/reader/top_bar.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | const Duration _duration = Duration(milliseconds: 500); 4 | const Curve _curve = Curves.fastOutSlowIn; 5 | 6 | class TopBar extends StatelessWidget { 7 | final String title; 8 | final bool isVisible; 9 | 10 | final void Function() onNavigateBack; 11 | 12 | const TopBar({ 13 | super.key, 14 | required this.title, 15 | required this.isVisible, 16 | required this.onNavigateBack, 17 | }); 18 | 19 | @override 20 | Widget build(BuildContext context) { 21 | final double statusBarHeight = MediaQuery.of(context).padding.top; 22 | return AnimatedPositioned( 23 | left: 0, 24 | right: 0, 25 | top: isVisible ? 0.0 : -(kToolbarHeight + statusBarHeight), 26 | duration: _duration, 27 | curve: _curve, 28 | child: AppBar( 29 | leading: IconButton( 30 | onPressed: onNavigateBack, 31 | icon: const Icon(Icons.arrow_back_rounded), 32 | ), 33 | title: Text(title), 34 | ), 35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /lib/ui/screens/search/search_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | import 'package:masiro/bloc/screen/search/search_screen_bloc.dart'; 4 | import 'package:masiro/bloc/screen/search/search_screen_state.dart'; 5 | import 'package:masiro/ui/screens/search/novel_list.dart'; 6 | import 'package:masiro/ui/screens/search/search_top_bar.dart'; 7 | import 'package:masiro/ui/widgets/error_message.dart'; 8 | 9 | class SearchScreen extends StatefulWidget { 10 | const SearchScreen({super.key}); 11 | 12 | @override 13 | State createState() => _SearchScreenState(); 14 | } 15 | 16 | class _SearchScreenState extends State { 17 | @override 18 | Widget build(BuildContext context) { 19 | return Material( 20 | child: SafeArea( 21 | child: BlocProvider( 22 | create: (_) => SearchScreenBloc(), 23 | child: Column( 24 | children: [ 25 | const SearchTopBar(), 26 | Expanded(child: buildBody(context)), 27 | ], 28 | ), 29 | ), 30 | ), 31 | ); 32 | } 33 | 34 | Widget buildBody(BuildContext context) { 35 | return BlocBuilder( 36 | builder: (context, state) { 37 | switch (state) { 38 | case SearchScreenLoadingState(): 39 | return const Center( 40 | child: SizedBox( 41 | width: 40, 42 | height: 40, 43 | child: CircularProgressIndicator(), 44 | ), 45 | ); 46 | case SearchScreenErrorState(): 47 | return ErrorMessage(message: state.message); 48 | case SearchScreenLoadedState(): 49 | return NovelList( 50 | novels: state.novels, 51 | status: state.infiniteListStatus, 52 | totalCount: state.totalCount, 53 | ); 54 | } 55 | }, 56 | ); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /lib/ui/screens/search/search_top_bar.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | import 'package:masiro/bloc/screen/search/search_screen_bloc.dart'; 4 | import 'package:masiro/bloc/screen/search/search_screen_event.dart'; 5 | 6 | const searchBarHeight = 72.0; 7 | 8 | class SearchTopBar extends StatefulWidget { 9 | const SearchTopBar({super.key}); 10 | 11 | @override 12 | State createState() => _SearchTopBarState(); 13 | } 14 | 15 | class _SearchTopBarState extends State { 16 | late final FocusNode _searchBarFocusNode; 17 | late final SearchController _searchController; 18 | 19 | @override 20 | void initState() { 21 | super.initState(); 22 | _searchBarFocusNode = FocusNode(debugLabel: 'Search Bar'); 23 | _searchController = SearchController(); 24 | } 25 | 26 | @override 27 | void dispose() { 28 | _searchBarFocusNode.dispose(); 29 | _searchController.dispose(); 30 | super.dispose(); 31 | } 32 | 33 | @override 34 | Widget build(BuildContext context) { 35 | final bloc = context.read(); 36 | return Padding( 37 | padding: const EdgeInsets.all(8.0), 38 | child: SearchAnchor( 39 | searchController: _searchController, 40 | viewOnSubmitted: (keyword) { 41 | bloc.add(SearchScreenSearched(keyword: keyword)); 42 | _searchController.closeView(keyword); 43 | _searchBarFocusNode.unfocus(); 44 | }, 45 | builder: (BuildContext context, SearchController controller) { 46 | return SearchBar( 47 | padding: const WidgetStatePropertyAll( 48 | EdgeInsets.symmetric(horizontal: 16.0), 49 | ), 50 | focusNode: _searchBarFocusNode, 51 | controller: controller, 52 | leading: const Icon(Icons.search), 53 | trailing: const [ 54 | // TODO(qixiao): Implement functionality to allow user to set filter options 55 | IconButton( 56 | icon: Icon(Icons.filter_list_rounded), 57 | onPressed: null, 58 | ), 59 | ], 60 | onTap: () => controller.openView(), 61 | ); 62 | }, 63 | suggestionsBuilder: (context, controller) { 64 | // Don't display any suggestions for now. 65 | return List.empty(growable: false); 66 | }, 67 | ), 68 | ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /lib/ui/screens/settings/about_card.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:go_router/go_router.dart'; 3 | import 'package:masiro/misc/context.dart'; 4 | import 'package:masiro/misc/router.dart'; 5 | 6 | class AboutCard extends StatelessWidget { 7 | const AboutCard({super.key}); 8 | 9 | @override 10 | Widget build(BuildContext context) { 11 | final localizations = context.localizations(); 12 | 13 | return Card( 14 | clipBehavior: Clip.hardEdge, 15 | child: ListTile( 16 | onTap: () => context.push(RoutePath.about), 17 | leading: const Icon(Icons.info), 18 | title: Text(localizations.about), 19 | ), 20 | ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /lib/ui/screens/settings/license_card.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:go_router/go_router.dart'; 3 | import 'package:masiro/misc/context.dart'; 4 | import 'package:masiro/misc/router.dart'; 5 | 6 | class LicenseCard extends StatelessWidget { 7 | const LicenseCard({super.key}); 8 | 9 | @override 10 | Widget build(BuildContext context) { 11 | final localizations = context.localizations(); 12 | 13 | return Card( 14 | clipBehavior: Clip.hardEdge, 15 | child: ListTile( 16 | onTap: () => context.push(RoutePath.licenses), 17 | leading: const Icon(Icons.code_rounded), 18 | title: Text(localizations.license), 19 | ), 20 | ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /lib/ui/screens/settings/logout_card.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | import 'package:go_router/go_router.dart'; 4 | import 'package:masiro/bloc/global/user/user_bloc.dart'; 5 | import 'package:masiro/bloc/screen/settings/settings_screen_bloc.dart'; 6 | import 'package:masiro/bloc/screen/settings/settings_screen_event.dart'; 7 | import 'package:masiro/misc/context.dart'; 8 | import 'package:masiro/misc/router.dart'; 9 | 10 | class LogoutCard extends StatelessWidget { 11 | const LogoutCard({super.key}); 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | final localizations = context.localizations(); 16 | 17 | return Card( 18 | clipBehavior: Clip.hardEdge, 19 | child: ListTile( 20 | textColor: Colors.red.shade500, 21 | iconColor: Colors.red.shade500, 22 | onTap: () => _logout(context), 23 | leading: const Icon(Icons.logout_rounded), 24 | title: Text(localizations.logout), 25 | ), 26 | ); 27 | } 28 | 29 | void _logout(BuildContext context) { 30 | final userBloc = context.read(); 31 | final settingsScreenBloc = context.read(); 32 | showDialog( 33 | context: context, 34 | builder: (context) { 35 | return _LogoutDialog( 36 | onLogout: () async { 37 | final needsRedirect = await userBloc.logout(); 38 | if (!needsRedirect) { 39 | settingsScreenBloc.add(SettingsScreenProfileRefreshed()); 40 | } 41 | if (!context.mounted || !needsRedirect) { 42 | return; 43 | } 44 | context.go(RoutePath.login); 45 | }, 46 | ); 47 | }, 48 | ); 49 | } 50 | } 51 | 52 | class _LogoutDialog extends StatelessWidget { 53 | final void Function() onLogout; 54 | 55 | const _LogoutDialog({required this.onLogout}); 56 | 57 | @override 58 | Widget build(BuildContext context) { 59 | final localizations = context.localizations(); 60 | 61 | return AlertDialog( 62 | title: Text(localizations.logout), 63 | content: Text(localizations.logoutPrompt), 64 | actions: [ 65 | TextButton( 66 | onPressed: () => Navigator.pop(context), 67 | child: Text(localizations.cancel), 68 | ), 69 | TextButton( 70 | onPressed: () { 71 | Navigator.pop(context); 72 | onLogout(); 73 | }, 74 | child: Text(localizations.confirm), 75 | ), 76 | ], 77 | ); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /lib/ui/screens/settings/profile_card.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:masiro/data/repository/model/profile.dart'; 3 | import 'package:masiro/misc/context.dart'; 4 | import 'package:masiro/misc/url.dart'; 5 | import 'package:masiro/ui/widgets/cached_image.dart'; 6 | 7 | const _placeholder = '-'; 8 | 9 | class ProfileCard extends StatelessWidget { 10 | final Profile? profile; 11 | 12 | const ProfileCard({super.key, this.profile}); 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | final colorScheme = context.colorScheme(); 17 | final localizations = context.localizations(); 18 | 19 | const avatarSize = 150.0; 20 | final avatar = profile?.avatar ?? MasiroUrl.defaultAvatar; 21 | final name = profile?.name ?? _placeholder; 22 | final level = profile?.level == null ? _placeholder : 'Lv${profile!.level}'; 23 | final coin = '${localizations.coin}: ${profile?.coinCount ?? _placeholder}'; 24 | final fan = '${localizations.fan}: ${profile?.fanCount ?? _placeholder}'; 25 | final id = '${localizations.id}: ${profile?.id ?? _placeholder}'; 26 | 27 | return Card( 28 | elevation: 0.0, 29 | color: colorScheme.surfaceContainer, 30 | child: Padding( 31 | padding: const EdgeInsets.symmetric(vertical: 20), 32 | child: Column( 33 | children: [ 34 | ClipOval( 35 | child: CachedImage( 36 | width: avatarSize, 37 | height: avatarSize, 38 | url: avatar.toUrl(), 39 | fit: BoxFit.cover, 40 | ), 41 | ), 42 | const SizedBox(height: 10), 43 | Row( 44 | mainAxisAlignment: MainAxisAlignment.center, 45 | children: [ 46 | Text(name), 47 | const SizedBox(width: 10), 48 | Badge(label: Text(level)), 49 | ], 50 | ), 51 | Wrap( 52 | spacing: 10, 53 | children: [ 54 | Text(coin), 55 | Text(fan), 56 | ], 57 | ), 58 | SelectableText(id), 59 | ], 60 | ), 61 | ), 62 | ); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /lib/ui/screens/settings/settings_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:easy_refresh/easy_refresh.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_bloc/flutter_bloc.dart'; 4 | import 'package:masiro/bloc/screen/settings/settings_screen_bloc.dart'; 5 | import 'package:masiro/bloc/screen/settings/settings_screen_event.dart'; 6 | import 'package:masiro/bloc/screen/settings/settings_screen_state.dart'; 7 | import 'package:masiro/misc/easy_refresh.dart'; 8 | import 'package:masiro/ui/screens/settings/about_card.dart'; 9 | import 'package:masiro/ui/screens/settings/accounts_card.dart'; 10 | import 'package:masiro/ui/screens/settings/logout_card.dart'; 11 | import 'package:masiro/ui/screens/settings/profile_card.dart'; 12 | import 'package:masiro/ui/screens/settings/sign_in_card.dart'; 13 | import 'package:masiro/ui/screens/settings/theme_color_card.dart'; 14 | import 'package:masiro/ui/screens/settings/theme_mode_card.dart'; 15 | 16 | class SettingsScreen extends StatefulWidget { 17 | const SettingsScreen({super.key}); 18 | 19 | @override 20 | State createState() => _SettingsScreenState(); 21 | } 22 | 23 | class _SettingsScreenState extends State { 24 | @override 25 | Widget build(BuildContext context) { 26 | return Material( 27 | child: SafeArea( 28 | child: BlocProvider( 29 | create: (_) => SettingsScreenBloc() 30 | ..add(SettingsScreenInitialized()) 31 | ..add(SettingsScreenProfileRequested()), 32 | child: BlocBuilder( 33 | builder: (context, state) { 34 | return buildScreen(context, state); 35 | }, 36 | ), 37 | ), 38 | ), 39 | ); 40 | } 41 | 42 | Widget buildScreen(BuildContext context, SettingsScreenState state) { 43 | final bloc = context.read(); 44 | const spacing = 20.0; 45 | 46 | return EasyRefresh( 47 | header: classicHeader(context), 48 | onRefresh: () async { 49 | bloc.add(SettingsScreenProfileRefreshed()); 50 | }, 51 | child: ListView( 52 | padding: const EdgeInsets.all(20), 53 | children: [ 54 | ProfileCard(profile: state.profile), 55 | const SizedBox(height: spacing), 56 | const SignInCard(), 57 | const AccountsCard(), 58 | const ThemeModeCard(), 59 | const ThemeColorCard(), 60 | const AboutCard(), 61 | const SizedBox(height: 20), 62 | const LogoutCard(), 63 | ], 64 | ), 65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /lib/ui/screens/settings/sign_in_card.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | import 'package:masiro/bloc/global/user/user_bloc.dart'; 4 | import 'package:masiro/bloc/global/user/user_state.dart'; 5 | import 'package:masiro/bloc/screen/settings/settings_screen_bloc.dart'; 6 | import 'package:masiro/bloc/screen/settings/settings_screen_event.dart'; 7 | import 'package:masiro/misc/context.dart'; 8 | import 'package:masiro/misc/time.dart'; 9 | import 'package:masiro/misc/toast.dart'; 10 | 11 | class SignInCard extends StatelessWidget { 12 | const SignInCard({super.key}); 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | final localizations = context.localizations(); 17 | final userBloc = context.read(); 18 | final settingsScreenBloc = context.read(); 19 | 20 | return BlocBuilder( 21 | builder: (context, state) { 22 | final lastSignInTime = state.currentUser?.lastSignInTime ?? 0; 23 | final hasSignedIn = isTimestampToday(lastSignInTime); 24 | 25 | return Card( 26 | clipBehavior: Clip.hardEdge, 27 | child: ListTile( 28 | onTap: hasSignedIn 29 | ? null 30 | : () => _signIn(userBloc, settingsScreenBloc), 31 | leading: hasSignedIn 32 | ? const Icon(Icons.lightbulb_rounded) 33 | : const Icon(Icons.lightbulb_outline_rounded), 34 | title: Text( 35 | hasSignedIn ? localizations.hasSignedIn : localizations.signIn, 36 | ), 37 | ), 38 | ); 39 | }, 40 | ); 41 | } 42 | 43 | Future _signIn( 44 | UserBloc userBloc, 45 | SettingsScreenBloc settingsScreenBloc, 46 | ) async { 47 | String msg; 48 | try { 49 | msg = await userBloc.signIn(); 50 | settingsScreenBloc.add(SettingsScreenProfileRefreshed()); 51 | } catch (e) { 52 | msg = e.toString(); 53 | } 54 | msg.toast(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /lib/ui/widgets/adaptive_status_bar_style.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/services.dart'; 3 | 4 | class AdaptiveStatusBarStyle extends StatelessWidget { 5 | const AdaptiveStatusBarStyle({super.key}); 6 | 7 | @override 8 | Widget build(BuildContext context) { 9 | final iconBrightness = Theme.of(context).brightness == Brightness.light 10 | ? Brightness.dark 11 | : Brightness.light; 12 | SystemChrome.setSystemUIOverlayStyle( 13 | SystemUiOverlayStyle( 14 | statusBarColor: Colors.transparent, 15 | statusBarIconBrightness: iconBrightness, 16 | ), 17 | ); 18 | return Container(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /lib/ui/widgets/after_layout.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/rendering.dart'; 3 | import 'package:flutter/scheduler.dart'; 4 | 5 | /// `AfterLayout` allows you to access the `RenderObject` of its `child`, 6 | /// so that you can get the size of the child widget. 7 | /// 8 | /// Source: https://book.flutterchina.club/chapter14/layout.html#_14-4-6-afterlayout 9 | class AfterLayout extends SingleChildRenderObjectWidget { 10 | final ValueSetter callback; 11 | 12 | const AfterLayout({ 13 | super.key, 14 | super.child, 15 | required this.callback, 16 | }); 17 | 18 | @override 19 | RenderObject createRenderObject(BuildContext context) { 20 | return RenderAfterLayout(callback); 21 | } 22 | 23 | @override 24 | void updateRenderObject( 25 | BuildContext context, 26 | RenderAfterLayout renderObject, 27 | ) { 28 | renderObject.callback = callback; 29 | } 30 | } 31 | 32 | class RenderAfterLayout extends RenderProxyBox { 33 | ValueSetter callback; 34 | 35 | RenderAfterLayout(this.callback); 36 | 37 | @override 38 | void performLayout() { 39 | super.performLayout(); 40 | SchedulerBinding.instance 41 | .addPostFrameCallback((timeStamp) => callback(this)); 42 | } 43 | 44 | Offset get offset => localToGlobal(Offset.zero); 45 | 46 | Rect get rect => offset & size; 47 | } 48 | -------------------------------------------------------------------------------- /lib/ui/widgets/cached_image.dart: -------------------------------------------------------------------------------- 1 | import 'package:cached_network_image/cached_network_image.dart'; 2 | import 'package:flutter/material.dart'; 3 | 4 | class CachedImage extends StatelessWidget { 5 | final String url; 6 | final double? width; 7 | final double? height; 8 | final BoxFit? fit; 9 | 10 | const CachedImage({ 11 | super.key, 12 | required this.url, 13 | required this.width, 14 | required this.height, 15 | this.fit, 16 | }); 17 | 18 | @override 19 | Widget build(BuildContext context) { 20 | return CachedNetworkImage( 21 | width: width, 22 | height: height, 23 | imageUrl: url, 24 | fit: fit, 25 | progressIndicatorBuilder: (context, url, progress) { 26 | return Container( 27 | color: Colors.black45, 28 | child: Center( 29 | child: SizedBox( 30 | width: 40, 31 | height: 40, 32 | child: CircularProgressIndicator( 33 | value: progress.progress, 34 | ), 35 | ), 36 | ), 37 | ); 38 | }, 39 | errorWidget: (context, url, error) { 40 | return Container( 41 | color: Colors.black45, 42 | child: const Center( 43 | child: Icon(Icons.error), 44 | ), 45 | ); 46 | }, 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /lib/ui/widgets/error_message.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:masiro/misc/context.dart'; 3 | 4 | class ErrorMessage extends StatelessWidget { 5 | final String? message; 6 | 7 | const ErrorMessage({super.key, this.message}); 8 | 9 | @override 10 | Widget build(BuildContext context) { 11 | final localizations = context.localizations(); 12 | return Material( 13 | child: Center( 14 | child: Padding( 15 | padding: const EdgeInsets.all(20), 16 | child: SelectionArea( 17 | child: Text( 18 | message ?? localizations.errorTip, 19 | textAlign: TextAlign.center, 20 | ), 21 | ), 22 | ), 23 | ), 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lib/ui/widgets/manual_tooltip.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:masiro/misc/platform.dart'; 3 | 4 | class ManualTooltip extends StatelessWidget { 5 | final Widget icon; 6 | final String tooltip; 7 | 8 | const ManualTooltip({ 9 | super.key, 10 | required this.icon, 11 | required this.tooltip, 12 | }); 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | final tooltipKey = GlobalKey(); 17 | 18 | return Tooltip( 19 | key: tooltipKey, 20 | triggerMode: TooltipTriggerMode.manual, 21 | message: tooltip, 22 | child: IconButton( 23 | onPressed: () { 24 | if (!isMobilePhone) { 25 | return; 26 | } 27 | tooltipKey.currentState?.ensureTooltipVisible(); 28 | }, 29 | icon: icon, 30 | ), 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /lib/ui/widgets/message.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class Message extends StatelessWidget { 4 | final String message; 5 | 6 | const Message({super.key, required this.message}); 7 | 8 | @override 9 | Widget build(BuildContext context) { 10 | return Material( 11 | child: Center( 12 | child: Text(message), 13 | ), 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /lib/ui/widgets/router_outlet_with_nav_bar.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:masiro/misc/platform.dart'; 3 | import 'package:masiro/misc/tray_icon.dart'; 4 | import 'package:masiro/ui/widgets/adaptive_status_bar_style.dart'; 5 | import 'package:masiro/ui/widgets/nav_bar.dart'; 6 | 7 | bool _systemTrayInitialized = false; 8 | 9 | class RouterOutletWithNavBar extends StatefulWidget { 10 | const RouterOutletWithNavBar({required this.child, super.key}); 11 | 12 | /// The widget to display in the body of the Scaffold. 13 | final Widget child; 14 | 15 | @override 16 | State createState() => _RouterOutletWithNavBarState(); 17 | } 18 | 19 | class _RouterOutletWithNavBarState extends State { 20 | @override 21 | void initState() { 22 | super.initState(); 23 | _initSystemTray(); 24 | } 25 | 26 | @override 27 | Widget build(BuildContext context) { 28 | return Scaffold( 29 | bottomNavigationBar: isMobilePhone ? const NavBar() : null, 30 | body: isDesktop 31 | ? Row( 32 | children: [ 33 | const NavBar(), 34 | const VerticalDivider( 35 | thickness: 0.0, 36 | width: 1.0, 37 | ), 38 | Expanded(child: Center(child: widget.child)), 39 | ], 40 | ) 41 | : Column( 42 | children: [ 43 | const AdaptiveStatusBarStyle(), 44 | Expanded(child: widget.child), 45 | ], 46 | ), 47 | ); 48 | } 49 | 50 | void _initSystemTray() { 51 | if (!isDesktop) { 52 | return; 53 | } 54 | 55 | WidgetsBinding.instance.addPostFrameCallback((_) { 56 | if (_systemTrayInitialized) { 57 | return; 58 | } 59 | initSystemTray(context); 60 | _systemTrayInitialized = true; 61 | }); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /linux/.gitignore: -------------------------------------------------------------------------------- 1 | flutter/ephemeral 2 | -------------------------------------------------------------------------------- /linux/flutter/generated_plugin_registrant.cc: -------------------------------------------------------------------------------- 1 | // 2 | // Generated file. Do not edit. 3 | // 4 | 5 | // clang-format off 6 | 7 | #include "generated_plugin_registrant.h" 8 | 9 | #include 10 | #include 11 | #include 12 | #include 13 | 14 | void fl_register_plugins(FlPluginRegistry* registry) { 15 | g_autoptr(FlPluginRegistrar) isar_flutter_libs_registrar = 16 | fl_plugin_registry_get_registrar_for_plugin(registry, "IsarFlutterLibsPlugin"); 17 | isar_flutter_libs_plugin_register_with_registrar(isar_flutter_libs_registrar); 18 | g_autoptr(FlPluginRegistrar) screen_retriever_registrar = 19 | fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverPlugin"); 20 | screen_retriever_plugin_register_with_registrar(screen_retriever_registrar); 21 | g_autoptr(FlPluginRegistrar) tray_manager_registrar = 22 | fl_plugin_registry_get_registrar_for_plugin(registry, "TrayManagerPlugin"); 23 | tray_manager_plugin_register_with_registrar(tray_manager_registrar); 24 | g_autoptr(FlPluginRegistrar) window_manager_registrar = 25 | fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); 26 | window_manager_plugin_register_with_registrar(window_manager_registrar); 27 | } 28 | -------------------------------------------------------------------------------- /linux/flutter/generated_plugin_registrant.h: -------------------------------------------------------------------------------- 1 | // 2 | // Generated file. Do not edit. 3 | // 4 | 5 | // clang-format off 6 | 7 | #ifndef GENERATED_PLUGIN_REGISTRANT_ 8 | #define GENERATED_PLUGIN_REGISTRANT_ 9 | 10 | #include 11 | 12 | // Registers Flutter plugins. 13 | void fl_register_plugins(FlPluginRegistry* registry); 14 | 15 | #endif // GENERATED_PLUGIN_REGISTRANT_ 16 | -------------------------------------------------------------------------------- /linux/flutter/generated_plugins.cmake: -------------------------------------------------------------------------------- 1 | # 2 | # Generated file, do not edit. 3 | # 4 | 5 | list(APPEND FLUTTER_PLUGIN_LIST 6 | isar_flutter_libs 7 | screen_retriever 8 | tray_manager 9 | window_manager 10 | ) 11 | 12 | list(APPEND FLUTTER_FFI_PLUGIN_LIST 13 | ) 14 | 15 | set(PLUGIN_BUNDLED_LIBRARIES) 16 | 17 | foreach(plugin ${FLUTTER_PLUGIN_LIST}) 18 | add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) 19 | target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) 20 | list(APPEND PLUGIN_BUNDLED_LIBRARIES $) 21 | list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) 22 | endforeach(plugin) 23 | 24 | foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) 25 | add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) 26 | list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) 27 | endforeach(ffi_plugin) 28 | -------------------------------------------------------------------------------- /linux/main.cc: -------------------------------------------------------------------------------- 1 | #include "my_application.h" 2 | 3 | int main(int argc, char** argv) { 4 | g_autoptr(MyApplication) app = my_application_new(); 5 | return g_application_run(G_APPLICATION(app), argc, argv); 6 | } 7 | -------------------------------------------------------------------------------- /linux/my_application.h: -------------------------------------------------------------------------------- 1 | #ifndef FLUTTER_MY_APPLICATION_H_ 2 | #define FLUTTER_MY_APPLICATION_H_ 3 | 4 | #include 5 | 6 | G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, 7 | GtkApplication) 8 | 9 | /** 10 | * my_application_new: 11 | * 12 | * Creates a new Flutter-based application. 13 | * 14 | * Returns: a new #MyApplication. 15 | */ 16 | MyApplication* my_application_new(); 17 | 18 | #endif // FLUTTER_MY_APPLICATION_H_ 19 | -------------------------------------------------------------------------------- /macos/.gitignore: -------------------------------------------------------------------------------- 1 | # Flutter-related 2 | **/Flutter/ephemeral/ 3 | **/Pods/ 4 | 5 | # Xcode-related 6 | **/dgph 7 | **/xcuserdata/ 8 | -------------------------------------------------------------------------------- /macos/Flutter/Flutter-Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include "ephemeral/Flutter-Generated.xcconfig" 2 | -------------------------------------------------------------------------------- /macos/Flutter/Flutter-Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include "ephemeral/Flutter-Generated.xcconfig" 2 | -------------------------------------------------------------------------------- /macos/Flutter/GeneratedPluginRegistrant.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Generated file. Do not edit. 3 | // 4 | 5 | import FlutterMacOS 6 | import Foundation 7 | 8 | import isar_flutter_libs 9 | import path_provider_foundation 10 | import screen_retriever 11 | import shared_preferences_foundation 12 | import sqflite 13 | import tray_manager 14 | import webview_flutter_wkwebview 15 | import window_manager 16 | 17 | func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { 18 | IsarFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "IsarFlutterLibsPlugin")) 19 | PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) 20 | ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin")) 21 | SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) 22 | SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) 23 | TrayManagerPlugin.register(with: registry.registrar(forPlugin: "TrayManagerPlugin")) 24 | FLTWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "FLTWebViewFlutterPlugin")) 25 | WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) 26 | } 27 | -------------------------------------------------------------------------------- /macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /macos/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /macos/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import FlutterMacOS 3 | 4 | @main 5 | class AppDelegate: FlutterAppDelegate { 6 | override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { 7 | return true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "16x16", 5 | "idiom" : "mac", 6 | "filename" : "app_icon_16.png", 7 | "scale" : "1x" 8 | }, 9 | { 10 | "size" : "16x16", 11 | "idiom" : "mac", 12 | "filename" : "app_icon_32.png", 13 | "scale" : "2x" 14 | }, 15 | { 16 | "size" : "32x32", 17 | "idiom" : "mac", 18 | "filename" : "app_icon_32.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "32x32", 23 | "idiom" : "mac", 24 | "filename" : "app_icon_64.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "128x128", 29 | "idiom" : "mac", 30 | "filename" : "app_icon_128.png", 31 | "scale" : "1x" 32 | }, 33 | { 34 | "size" : "128x128", 35 | "idiom" : "mac", 36 | "filename" : "app_icon_256.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "256x256", 41 | "idiom" : "mac", 42 | "filename" : "app_icon_256.png", 43 | "scale" : "1x" 44 | }, 45 | { 46 | "size" : "256x256", 47 | "idiom" : "mac", 48 | "filename" : "app_icon_512.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "512x512", 53 | "idiom" : "mac", 54 | "filename" : "app_icon_512.png", 55 | "scale" : "1x" 56 | }, 57 | { 58 | "size" : "512x512", 59 | "idiom" : "mac", 60 | "filename" : "app_icon_1024.png", 61 | "scale" : "2x" 62 | } 63 | ], 64 | "info" : { 65 | "version" : 1, 66 | "author" : "xcode" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qixiaoo/masiro/eff339269371e50d432663d4a55402ba3d8d582c/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png -------------------------------------------------------------------------------- /macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qixiaoo/masiro/eff339269371e50d432663d4a55402ba3d8d582c/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png -------------------------------------------------------------------------------- /macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qixiaoo/masiro/eff339269371e50d432663d4a55402ba3d8d582c/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png -------------------------------------------------------------------------------- /macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qixiaoo/masiro/eff339269371e50d432663d4a55402ba3d8d582c/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png -------------------------------------------------------------------------------- /macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qixiaoo/masiro/eff339269371e50d432663d4a55402ba3d8d582c/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png -------------------------------------------------------------------------------- /macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qixiaoo/masiro/eff339269371e50d432663d4a55402ba3d8d582c/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png -------------------------------------------------------------------------------- /macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qixiaoo/masiro/eff339269371e50d432663d4a55402ba3d8d582c/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png -------------------------------------------------------------------------------- /macos/Runner/Configs/AppInfo.xcconfig: -------------------------------------------------------------------------------- 1 | // Application-level settings for the Runner target. 2 | // 3 | // This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the 4 | // future. If not, the values below would default to using the project name when this becomes a 5 | // 'flutter create' template. 6 | 7 | // The application's name. By default this is also the title of the Flutter window. 8 | PRODUCT_NAME = masiro 9 | 10 | // The application's bundle identifier 11 | PRODUCT_BUNDLE_IDENTIFIER = com.example.masiro 12 | 13 | // The copyright displayed in application information 14 | PRODUCT_COPYRIGHT = Copyright © 2024 com.example. All rights reserved. 15 | -------------------------------------------------------------------------------- /macos/Runner/Configs/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include "../../Flutter/Flutter-Debug.xcconfig" 2 | #include "Warnings.xcconfig" 3 | -------------------------------------------------------------------------------- /macos/Runner/Configs/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include "../../Flutter/Flutter-Release.xcconfig" 2 | #include "Warnings.xcconfig" 3 | -------------------------------------------------------------------------------- /macos/Runner/Configs/Warnings.xcconfig: -------------------------------------------------------------------------------- 1 | WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings 2 | GCC_WARN_UNDECLARED_SELECTOR = YES 3 | CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES 4 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE 5 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES 6 | CLANG_WARN_PRAGMA_PACK = YES 7 | CLANG_WARN_STRICT_PROTOTYPES = YES 8 | CLANG_WARN_COMMA = YES 9 | GCC_WARN_STRICT_SELECTOR_MATCH = YES 10 | CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES 11 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES 12 | GCC_WARN_SHADOW = YES 13 | CLANG_WARN_UNREACHABLE_CODE = YES 14 | -------------------------------------------------------------------------------- /macos/Runner/DebugProfile.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.cs.allow-jit 8 | 9 | com.apple.security.network.server 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /macos/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | $(FLUTTER_BUILD_NAME) 21 | CFBundleVersion 22 | $(FLUTTER_BUILD_NUMBER) 23 | LSMinimumSystemVersion 24 | $(MACOSX_DEPLOYMENT_TARGET) 25 | NSHumanReadableCopyright 26 | $(PRODUCT_COPYRIGHT) 27 | NSMainNibFile 28 | MainMenu 29 | NSPrincipalClass 30 | NSApplication 31 | 32 | 33 | -------------------------------------------------------------------------------- /macos/Runner/MainFlutterWindow.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import FlutterMacOS 3 | 4 | class MainFlutterWindow: NSWindow { 5 | override func awakeFromNib() { 6 | let flutterViewController = FlutterViewController() 7 | let windowFrame = self.frame 8 | self.contentViewController = flutterViewController 9 | self.setFrame(windowFrame, display: true) 10 | 11 | RegisterGeneratedPlugins(registry: flutterViewController) 12 | 13 | super.awakeFromNib() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /macos/Runner/Release.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /macos/RunnerTests/RunnerTests.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import FlutterMacOS 3 | import XCTest 4 | 5 | class RunnerTests: XCTestCase { 6 | 7 | func testExample() { 8 | // If you add code to the Runner application, consider adding tests here. 9 | // See https://developer.apple.com/documentation/xctest for more information about using XCTest. 10 | } 11 | 12 | } 13 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: masiro 2 | description: "A masiro client." 3 | publish_to: 'none' 4 | 5 | version: 0.1.0+13 6 | 7 | environment: 8 | sdk: '>=3.4.4 <4.0.0' 9 | 10 | isar_version: &isar_version 3.1.0+1 11 | 12 | 13 | dependencies: 14 | flutter: 15 | sdk: flutter 16 | flutter_localizations: 17 | sdk: flutter 18 | intl: any 19 | 20 | cupertino_icons: ^1.0.6 21 | tray_manager: ^0.2.3 22 | window_manager: ^0.3.9 23 | go_router: ^14.2.1 24 | rxdart: ^0.28.0 25 | flutter_bloc: ^8.1.6 26 | flutter_smart_dialog: ^4.9.7+9 27 | shared_preferences: ^2.2.3 28 | dio: ^5.6.0 29 | cookie_jar: ^4.0.8 30 | dio_cookie_manager: ^3.1.1 31 | path: ^1.9.0 32 | path_provider: ^2.1.4 33 | html: ^0.15.4 34 | logger: ^2.4.0 35 | puppeteer: ^3.12.0 36 | get_it: ^7.7.0 37 | injectable: ^2.4.4 38 | easy_refresh: ^3.4.0 39 | cached_network_image: ^3.4.1 40 | equatable: ^2.0.5 41 | dio_cache_interceptor: ^3.5.0 42 | dio_cache_interceptor_isar_store: ^1.0.1 43 | isar: *isar_version 44 | isar_flutter_libs: *isar_version 45 | webview_flutter: ^4.9.0 46 | webview_cookie_manager: ^2.0.6 47 | flex_color_picker: ^3.6.0 48 | scrollable_positioned_list: ^0.3.8 49 | flutter_widget_from_html_core: ^0.15.2 50 | 51 | 52 | dev_dependencies: 53 | flutter_test: 54 | sdk: flutter 55 | 56 | flutter_lints: ^4.0.0 57 | build_runner: any 58 | injectable_generator: ^2.4.2 59 | isar_generator: *isar_version 60 | flutter_launcher_icons: ^0.14.0 61 | flutter_oss_licenses: ^3.0.2 62 | 63 | 64 | flutter: 65 | generate: true 66 | uses-material-design: true 67 | assets: 68 | - pubspec.yaml 69 | - assets/icon/icon.png 70 | -------------------------------------------------------------------------------- /test/widget_test.dart: -------------------------------------------------------------------------------- 1 | // This is a basic Flutter widget test. 2 | // 3 | // To perform an interaction with a widget in your test, use the WidgetTester 4 | // utility in the flutter_test package. For example, you can send tap and scroll 5 | // gestures. You can also use WidgetTester to find child widgets in the widget 6 | // tree, read text, and verify that the values of widget properties are correct. 7 | 8 | import 'package:flutter_test/flutter_test.dart'; 9 | import 'package:masiro/ui/app.dart'; 10 | 11 | void main() { 12 | testWidgets('Counter increments smoke test', (WidgetTester tester) async { 13 | // Build our app and trigger a frame. 14 | await tester.pumpWidget(const App()); 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /windows/.gitignore: -------------------------------------------------------------------------------- 1 | flutter/ephemeral/ 2 | 3 | # Visual Studio user-specific files. 4 | *.suo 5 | *.user 6 | *.userosscache 7 | *.sln.docstates 8 | 9 | # Visual Studio build-related files. 10 | x64/ 11 | x86/ 12 | 13 | # Visual Studio cache files 14 | # files ending in .cache can be ignored 15 | *.[Cc]ache 16 | # but keep track of directories ending in .cache 17 | !*.[Cc]ache/ 18 | -------------------------------------------------------------------------------- /windows/flutter/generated_plugin_registrant.cc: -------------------------------------------------------------------------------- 1 | // 2 | // Generated file. Do not edit. 3 | // 4 | 5 | // clang-format off 6 | 7 | #include "generated_plugin_registrant.h" 8 | 9 | #include 10 | #include 11 | #include 12 | #include 13 | 14 | void RegisterPlugins(flutter::PluginRegistry* registry) { 15 | IsarFlutterLibsPluginRegisterWithRegistrar( 16 | registry->GetRegistrarForPlugin("IsarFlutterLibsPlugin")); 17 | ScreenRetrieverPluginRegisterWithRegistrar( 18 | registry->GetRegistrarForPlugin("ScreenRetrieverPlugin")); 19 | TrayManagerPluginRegisterWithRegistrar( 20 | registry->GetRegistrarForPlugin("TrayManagerPlugin")); 21 | WindowManagerPluginRegisterWithRegistrar( 22 | registry->GetRegistrarForPlugin("WindowManagerPlugin")); 23 | } 24 | -------------------------------------------------------------------------------- /windows/flutter/generated_plugin_registrant.h: -------------------------------------------------------------------------------- 1 | // 2 | // Generated file. Do not edit. 3 | // 4 | 5 | // clang-format off 6 | 7 | #ifndef GENERATED_PLUGIN_REGISTRANT_ 8 | #define GENERATED_PLUGIN_REGISTRANT_ 9 | 10 | #include 11 | 12 | // Registers Flutter plugins. 13 | void RegisterPlugins(flutter::PluginRegistry* registry); 14 | 15 | #endif // GENERATED_PLUGIN_REGISTRANT_ 16 | -------------------------------------------------------------------------------- /windows/flutter/generated_plugins.cmake: -------------------------------------------------------------------------------- 1 | # 2 | # Generated file, do not edit. 3 | # 4 | 5 | list(APPEND FLUTTER_PLUGIN_LIST 6 | isar_flutter_libs 7 | screen_retriever 8 | tray_manager 9 | window_manager 10 | ) 11 | 12 | list(APPEND FLUTTER_FFI_PLUGIN_LIST 13 | ) 14 | 15 | set(PLUGIN_BUNDLED_LIBRARIES) 16 | 17 | foreach(plugin ${FLUTTER_PLUGIN_LIST}) 18 | add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) 19 | target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) 20 | list(APPEND PLUGIN_BUNDLED_LIBRARIES $) 21 | list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) 22 | endforeach(plugin) 23 | 24 | foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) 25 | add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) 26 | list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) 27 | endforeach(ffi_plugin) 28 | -------------------------------------------------------------------------------- /windows/runner/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.14) 2 | project(runner LANGUAGES CXX) 3 | 4 | # Define the application target. To change its name, change BINARY_NAME in the 5 | # top-level CMakeLists.txt, not the value here, or `flutter run` will no longer 6 | # work. 7 | # 8 | # Any new source files that you add to the application should be added here. 9 | add_executable(${BINARY_NAME} WIN32 10 | "flutter_window.cpp" 11 | "main.cpp" 12 | "utils.cpp" 13 | "win32_window.cpp" 14 | "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" 15 | "Runner.rc" 16 | "runner.exe.manifest" 17 | ) 18 | 19 | # Apply the standard set of build settings. This can be removed for applications 20 | # that need different build settings. 21 | apply_standard_settings(${BINARY_NAME}) 22 | 23 | # Add preprocessor definitions for the build version. 24 | target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") 25 | target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") 26 | target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") 27 | target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") 28 | target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") 29 | 30 | # Disable Windows macros that collide with C++ standard library functions. 31 | target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") 32 | 33 | # Add dependency libraries and include directories. Add any application-specific 34 | # dependencies here. 35 | target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) 36 | target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") 37 | target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") 38 | 39 | # Run the Flutter tool portions of the build. This must not be removed. 40 | add_dependencies(${BINARY_NAME} flutter_assemble) 41 | -------------------------------------------------------------------------------- /windows/runner/flutter_window.cpp: -------------------------------------------------------------------------------- 1 | #include "flutter_window.h" 2 | 3 | #include 4 | 5 | #include "flutter/generated_plugin_registrant.h" 6 | 7 | FlutterWindow::FlutterWindow(const flutter::DartProject& project) 8 | : project_(project) {} 9 | 10 | FlutterWindow::~FlutterWindow() {} 11 | 12 | bool FlutterWindow::OnCreate() { 13 | if (!Win32Window::OnCreate()) { 14 | return false; 15 | } 16 | 17 | RECT frame = GetClientArea(); 18 | 19 | // The size here must match the window dimensions to avoid unnecessary surface 20 | // creation / destruction in the startup path. 21 | flutter_controller_ = std::make_unique( 22 | frame.right - frame.left, frame.bottom - frame.top, project_); 23 | // Ensure that basic setup of the controller was successful. 24 | if (!flutter_controller_->engine() || !flutter_controller_->view()) { 25 | return false; 26 | } 27 | RegisterPlugins(flutter_controller_->engine()); 28 | SetChildContent(flutter_controller_->view()->GetNativeWindow()); 29 | 30 | flutter_controller_->engine()->SetNextFrameCallback([&]() { 31 | this->Show(); 32 | }); 33 | 34 | // Flutter can complete the first frame before the "show window" callback is 35 | // registered. The following call ensures a frame is pending to ensure the 36 | // window is shown. It is a no-op if the first frame hasn't completed yet. 37 | flutter_controller_->ForceRedraw(); 38 | 39 | return true; 40 | } 41 | 42 | void FlutterWindow::OnDestroy() { 43 | if (flutter_controller_) { 44 | flutter_controller_ = nullptr; 45 | } 46 | 47 | Win32Window::OnDestroy(); 48 | } 49 | 50 | LRESULT 51 | FlutterWindow::MessageHandler(HWND hwnd, UINT const message, 52 | WPARAM const wparam, 53 | LPARAM const lparam) noexcept { 54 | // Give Flutter, including plugins, an opportunity to handle window messages. 55 | if (flutter_controller_) { 56 | std::optional result = 57 | flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, 58 | lparam); 59 | if (result) { 60 | return *result; 61 | } 62 | } 63 | 64 | switch (message) { 65 | case WM_FONTCHANGE: 66 | flutter_controller_->engine()->ReloadSystemFonts(); 67 | break; 68 | } 69 | 70 | return Win32Window::MessageHandler(hwnd, message, wparam, lparam); 71 | } 72 | -------------------------------------------------------------------------------- /windows/runner/flutter_window.h: -------------------------------------------------------------------------------- 1 | #ifndef RUNNER_FLUTTER_WINDOW_H_ 2 | #define RUNNER_FLUTTER_WINDOW_H_ 3 | 4 | #include 5 | #include 6 | 7 | #include 8 | 9 | #include "win32_window.h" 10 | 11 | // A window that does nothing but host a Flutter view. 12 | class FlutterWindow : public Win32Window { 13 | public: 14 | // Creates a new FlutterWindow hosting a Flutter view running |project|. 15 | explicit FlutterWindow(const flutter::DartProject& project); 16 | virtual ~FlutterWindow(); 17 | 18 | protected: 19 | // Win32Window: 20 | bool OnCreate() override; 21 | void OnDestroy() override; 22 | LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, 23 | LPARAM const lparam) noexcept override; 24 | 25 | private: 26 | // The project to run. 27 | flutter::DartProject project_; 28 | 29 | // The Flutter instance hosted by this window. 30 | std::unique_ptr flutter_controller_; 31 | }; 32 | 33 | #endif // RUNNER_FLUTTER_WINDOW_H_ 34 | -------------------------------------------------------------------------------- /windows/runner/main.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | #include "flutter_window.h" 6 | #include "utils.h" 7 | 8 | int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, 9 | _In_ wchar_t *command_line, _In_ int show_command) { 10 | // Attach to console when present (e.g., 'flutter run') or create a 11 | // new console when running with a debugger. 12 | if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { 13 | CreateAndAttachConsole(); 14 | } 15 | 16 | // Initialize COM, so that it is available for use in the library and/or 17 | // plugins. 18 | ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); 19 | 20 | flutter::DartProject project(L"data"); 21 | 22 | std::vector command_line_arguments = 23 | GetCommandLineArguments(); 24 | 25 | project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); 26 | 27 | FlutterWindow window(project); 28 | Win32Window::Point origin(10, 10); 29 | Win32Window::Size size(1280, 720); 30 | if (!window.Create(L"masiro", origin, size)) { 31 | return EXIT_FAILURE; 32 | } 33 | window.SetQuitOnClose(true); 34 | 35 | ::MSG msg; 36 | while (::GetMessage(&msg, nullptr, 0, 0)) { 37 | ::TranslateMessage(&msg); 38 | ::DispatchMessage(&msg); 39 | } 40 | 41 | ::CoUninitialize(); 42 | return EXIT_SUCCESS; 43 | } 44 | -------------------------------------------------------------------------------- /windows/runner/resource.h: -------------------------------------------------------------------------------- 1 | //{{NO_DEPENDENCIES}} 2 | // Microsoft Visual C++ generated include file. 3 | // Used by Runner.rc 4 | // 5 | #define IDI_APP_ICON 101 6 | 7 | // Next default values for new objects 8 | // 9 | #ifdef APSTUDIO_INVOKED 10 | #ifndef APSTUDIO_READONLY_SYMBOLS 11 | #define _APS_NEXT_RESOURCE_VALUE 102 12 | #define _APS_NEXT_COMMAND_VALUE 40001 13 | #define _APS_NEXT_CONTROL_VALUE 1001 14 | #define _APS_NEXT_SYMED_VALUE 101 15 | #endif 16 | #endif 17 | -------------------------------------------------------------------------------- /windows/runner/resources/app_icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qixiaoo/masiro/eff339269371e50d432663d4a55402ba3d8d582c/windows/runner/resources/app_icon.ico -------------------------------------------------------------------------------- /windows/runner/runner.exe.manifest: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PerMonitorV2 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /windows/runner/utils.cpp: -------------------------------------------------------------------------------- 1 | #include "utils.h" 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | #include 9 | 10 | void CreateAndAttachConsole() { 11 | if (::AllocConsole()) { 12 | FILE *unused; 13 | if (freopen_s(&unused, "CONOUT$", "w", stdout)) { 14 | _dup2(_fileno(stdout), 1); 15 | } 16 | if (freopen_s(&unused, "CONOUT$", "w", stderr)) { 17 | _dup2(_fileno(stdout), 2); 18 | } 19 | std::ios::sync_with_stdio(); 20 | FlutterDesktopResyncOutputStreams(); 21 | } 22 | } 23 | 24 | std::vector GetCommandLineArguments() { 25 | // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. 26 | int argc; 27 | wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); 28 | if (argv == nullptr) { 29 | return std::vector(); 30 | } 31 | 32 | std::vector command_line_arguments; 33 | 34 | // Skip the first argument as it's the binary name. 35 | for (int i = 1; i < argc; i++) { 36 | command_line_arguments.push_back(Utf8FromUtf16(argv[i])); 37 | } 38 | 39 | ::LocalFree(argv); 40 | 41 | return command_line_arguments; 42 | } 43 | 44 | std::string Utf8FromUtf16(const wchar_t* utf16_string) { 45 | if (utf16_string == nullptr) { 46 | return std::string(); 47 | } 48 | unsigned int target_length = ::WideCharToMultiByte( 49 | CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, 50 | -1, nullptr, 0, nullptr, nullptr) 51 | -1; // remove the trailing null character 52 | int input_length = (int)wcslen(utf16_string); 53 | std::string utf8_string; 54 | if (target_length == 0 || target_length > utf8_string.max_size()) { 55 | return utf8_string; 56 | } 57 | utf8_string.resize(target_length); 58 | int converted_length = ::WideCharToMultiByte( 59 | CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, 60 | input_length, utf8_string.data(), target_length, nullptr, nullptr); 61 | if (converted_length == 0) { 62 | return std::string(); 63 | } 64 | return utf8_string; 65 | } 66 | -------------------------------------------------------------------------------- /windows/runner/utils.h: -------------------------------------------------------------------------------- 1 | #ifndef RUNNER_UTILS_H_ 2 | #define RUNNER_UTILS_H_ 3 | 4 | #include 5 | #include 6 | 7 | // Creates a console for the process, and redirects stdout and stderr to 8 | // it for both the runner and the Flutter library. 9 | void CreateAndAttachConsole(); 10 | 11 | // Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string 12 | // encoded in UTF-8. Returns an empty std::string on failure. 13 | std::string Utf8FromUtf16(const wchar_t* utf16_string); 14 | 15 | // Gets the command line arguments passed in as a std::vector, 16 | // encoded in UTF-8. Returns an empty std::vector on failure. 17 | std::vector GetCommandLineArguments(); 18 | 19 | #endif // RUNNER_UTILS_H_ 20 | --------------------------------------------------------------------------------