From 634ae98decb33b3d1970ebf786f38b864d54a939 Mon Sep 17 00:00:00 2001 From: JIMME Date: Wed, 3 Jun 2026 10:39:03 +0900 Subject: [PATCH] chore: prepare yanting monorepo handoff --- .gitignore | 47 + .metadata | 30 + NOTES.md | 28 + README.md | 77 ++ analysis_options.yaml | 28 + android/.gitignore | 11 + android/app/build.gradle.kts | 45 + android/app/src/debug/AndroidManifest.xml | 8 + android/app/src/main/AndroidManifest.xml | 47 + .../report_notebooklm_app/MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + android/app/src/main/res/values/styles.xml | 18 + android/app/src/profile/AndroidManifest.xml | 7 + android/build.gradle.kts | 24 + android/gradle.properties | 6 + android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 53636 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 + android/gradlew | 160 +++ android/gradlew.bat | 90 ++ android/settings.gradle.kts | 26 + docs/API_CONTRACT_NOTES.md | 158 +++ docs/APP_RUNBOOK.md | 81 ++ docs/HANDOFF.md | 79 ++ docs/PROJECT_BRIEF.md | 65 ++ docs/PROJECT_MAP.md | 75 ++ lib/app.dart | 135 +++ lib/data/api/report_data_source.dart | 77 ++ lib/data/models/models.dart | 313 ++++++ .../detail/modules/renderer_registry.dart | 993 ++++++++++++++++++ lib/features/detail/report_detail_page.dart | 199 ++++ lib/features/feed/feed_page.dart | 124 +++ .../institutions/institution_detail_page.dart | 101 ++ .../institutions/institutions_page.dart | 102 ++ lib/features/listen/listen_page.dart | 70 ++ lib/features/profile/profile_page.dart | 80 ++ lib/features/reports/reports_page.dart | 157 +++ lib/features/shared/report_card_widget.dart | 87 ++ lib/features/shell_page.dart | 152 +++ lib/main.dart | 12 + lib/routing/app_routes.dart | 47 + lib/theme/app_theme.dart | 98 ++ lib/theme/wise_tokens.dart | 71 ++ lib/widgets/app_buttons.dart | 46 + lib/widgets/app_card.dart | 53 + lib/widgets/badges.dart | 79 ++ lib/widgets/mini_player.dart | 188 ++++ lib/widgets/sheets.dart | 86 ++ lib/widgets/states.dart | 135 +++ pubspec.lock | 245 +++++ pubspec.yaml | 90 ++ test/widget_test.dart | 161 +++ web/favicon.png | Bin 0 -> 917 bytes web/icons/Icon-192.png | Bin 0 -> 5292 bytes web/icons/Icon-512.png | Bin 0 -> 8252 bytes web/icons/Icon-maskable-192.png | Bin 0 -> 5594 bytes web/icons/Icon-maskable-512.png | Bin 0 -> 20998 bytes web/index.html | 46 + web/manifest.json | 35 + 65 files changed, 5144 insertions(+) create mode 100644 .gitignore create mode 100644 .metadata create mode 100644 NOTES.md create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 android/.gitignore create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/src/debug/AndroidManifest.xml create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/kotlin/com/example/report_notebooklm_app/MainActivity.kt create mode 100644 android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 android/app/src/main/res/drawable/launch_background.xml create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/values-night/styles.xml create mode 100644 android/app/src/main/res/values/styles.xml create mode 100644 android/app/src/profile/AndroidManifest.xml create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/settings.gradle.kts create mode 100644 docs/API_CONTRACT_NOTES.md create mode 100644 docs/APP_RUNBOOK.md create mode 100644 docs/HANDOFF.md create mode 100644 docs/PROJECT_BRIEF.md create mode 100644 docs/PROJECT_MAP.md create mode 100644 lib/app.dart create mode 100644 lib/data/api/report_data_source.dart create mode 100644 lib/data/models/models.dart create mode 100644 lib/features/detail/modules/renderer_registry.dart create mode 100644 lib/features/detail/report_detail_page.dart create mode 100644 lib/features/feed/feed_page.dart create mode 100644 lib/features/institutions/institution_detail_page.dart create mode 100644 lib/features/institutions/institutions_page.dart create mode 100644 lib/features/listen/listen_page.dart create mode 100644 lib/features/profile/profile_page.dart create mode 100644 lib/features/reports/reports_page.dart create mode 100644 lib/features/shared/report_card_widget.dart create mode 100644 lib/features/shell_page.dart create mode 100644 lib/main.dart create mode 100644 lib/routing/app_routes.dart create mode 100644 lib/theme/app_theme.dart create mode 100644 lib/theme/wise_tokens.dart create mode 100644 lib/widgets/app_buttons.dart create mode 100644 lib/widgets/app_card.dart create mode 100644 lib/widgets/badges.dart create mode 100644 lib/widgets/mini_player.dart create mode 100644 lib/widgets/sheets.dart create mode 100644 lib/widgets/states.dart create mode 100644 pubspec.lock create mode 100644 pubspec.yaml create mode 100644 test/widget_test.dart create mode 100644 web/favicon.png create mode 100644 web/icons/Icon-192.png create mode 100644 web/icons/Icon-512.png create mode 100644 web/icons/Icon-maskable-192.png create mode 100644 web/icons/Icon-maskable-512.png create mode 100644 web/index.html create mode 100644 web/manifest.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..01371e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ +*.apk +build/verification/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..1e65aef --- /dev/null +++ b/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "924134a44c189315be2148659913dda1671cbe99" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 924134a44c189315be2148659913dda1671cbe99 + base_revision: 924134a44c189315be2148659913dda1671cbe99 + - platform: android + create_revision: 924134a44c189315be2148659913dda1671cbe99 + base_revision: 924134a44c189315be2148659913dda1671cbe99 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..ac366e7 --- /dev/null +++ b/NOTES.md @@ -0,0 +1,28 @@ +# report-notebooklm-app NOTES + +## 2026-06-03 + +- App prototype pass moved the Flutter client from a single `lib/main.dart` shell into feature/domain folders: + - `lib/theme/`: Wise-style tokens and `ThemeData`. + - `lib/data/`: typed API data source and models for reports, institutions, display modules, audio, and module detail. + - `lib/features/`: feed, reports, institutions, listen, profile, and detail pages. + - `lib/features/detail/modules/`: renderer registry and module detail routing. + - `lib/widgets/`: reusable cards, badges/chips, buttons, states, sheets, and mini/player UI. +- User-visible product name is `研听`; code/package/API identifiers remain `report-notebooklm` / `rnb`. +- Current UI uses real backend seed/API responses. Local UI mock is limited to blocked Phase 1 behaviors: login, save/favorite state, outbound confirmation, and audio progress without a real stream. +- Product decision: Phase 1 has no report-interpretation download feature. Do not show a top-level download icon, Detail download button, profile download record, download API, audio offline package, or download login trigger. Original reports are accessed only through source/outbound links from source/compliance surfaces. +- 2026-06-03 prototype feedback pass: + - Detail module cards are clickable as a whole and keep a `查看详情` affordance. + - Module detail pages should use vertical `subtitle + content` flows, not left/right comparison layouts. + - Report source, publisher parameters, copyright note, and disclaimer render in one `报告来源` card; do not re-add a separate `发布机构` card or bottom `来源与合规` card. + - `局限与疑问` shows the "需要继续验证" reminder once at the bottom, not after every paragraph. + - Custom overscroll feedback must move content over whitespace and rebound smoothly; do not stretch text or relayout cards during pull. + - If report detail does not open in an emulator or device, first verify that the backend is running and that `RNB_API_BASE` is reachable from that runtime. Host loopback, emulator, and physical-device networking are different environments. +- Known remaining gaps: + - Real audio stream: needs backend `/audio/{audio_id}/stream` or equivalent playable URL in `/listen`. + - Auth and personal state sync: favorites, history, saved listens, and playback progress remain local UI placeholders. + - Outbound events: confirmation sheet exists, but `POST /outbound/events` is not implemented. + - Production API domain: app still requires explicit `--dart-define=RNB_API_BASE=...`. + - Release signing: not configured beyond Flutter scaffold defaults. + - App icon/final brand visual: not finalized. + - Backend pagination/cache/OSS signed URL: list pagination is still seed-scale; Redis/signed URL policies remain backend work. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2a92677 --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# report-notebooklm-app + +Flutter client for the report-notebooklm Phase 1 app shell. + +The backend API lives in `../report-notebooklm-api/` in the same monorepo. API/data/content-pipeline details are documented there; this directory focuses on App handoff, UI state, build commands, and integration notes. + +## Read First + +- [docs/HANDOFF.md](docs/HANDOFF.md): current App state, implemented screens, placeholders, and next work. +- [docs/PROJECT_BRIEF.md](docs/PROJECT_BRIEF.md): product and Phase 1 scope snapshot. +- [docs/APP_RUNBOOK.md](docs/APP_RUNBOOK.md): Flutter version, local run, web build, Android debug build, and verification. +- [docs/API_CONTRACT_NOTES.md](docs/API_CONTRACT_NOTES.md): endpoints and fields consumed by the App. +- [docs/PROJECT_MAP.md](docs/PROJECT_MAP.md): source tree map. + +## Product Boundary + +This repo contains App code and an engineering handoff snapshot. It is not the product source of truth. + +Product SSOT: mall-docs report-notebooklm docs. Snapshot date: 2026-06-03. + +Use `report-notebooklm` and `rnb` for technical identifiers. The user-facing product name is `研听`. + +## Requirements + +- Flutter 3.44.1 / Dart 3.12.1 or compatible newer versions. +- A running backend that serves `/api/report-notebooklm/v1`. +- For Android builds: Android SDK, accepted licenses, and an emulator or device. + +## API Base URL + +The App intentionally has no built-in live API default. Pass the backend base URL explicitly: + +```bash +flutter run -d chrome --dart-define=RNB_API_BASE= +``` + +Android emulator: + +```bash +flutter run -d --dart-define=RNB_API_BASE= +``` + +Same-network Android device: + +```bash +flutter run -d --dart-define=RNB_API_BASE=http://:/api/report-notebooklm/v1 +``` + +Only use cleartext HTTP for debug builds. Release builds must use HTTPS. + +## Verify + +```bash +flutter analyze +flutter test +flutter build web --dart-define=RNB_API_BASE= +flutter build apk --debug --dart-define=RNB_API_BASE= +``` + +## Current App Scope + +Implemented: + +- Five bottom tabs: 推荐, 研报, 机构, 听单, 我的. +- API-backed feed, report list, institution list, listen list, institution detail, and report detail. +- Module renderer registry for inline and card-plus-page modules. +- Product display name `研听`. +- Local UI placeholders for login, favorite, outbound confirmation, and playback progress. + +Not implemented yet: + +- Real auth. +- Real favorite/history/saved-listen sync. +- Real playable audio stream. +- Real outbound event write. +- Production API domain. +- Release signing, final icon, and final store metadata. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be82512 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,11 @@ +/.gradle +/captures/ +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..ebddeb3 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.report_notebooklm_app" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.report_notebooklm_app" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..d16cdf3 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d68fb56 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/report_notebooklm_app/MainActivity.kt b/android/app/src/main/kotlin/com/example/report_notebooklm_app/MainActivity.kt new file mode 100644 index 0000000..4ab88e7 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/report_notebooklm_app/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.report_notebooklm_app + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..13372aef5e24af05341d49695ee84e5f9b594659 GIT binary patch literal 53636 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ⅅ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 H-+%o-(+5aQ literal 0 HcmV?d00001 diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2d428bf --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..9d82f78 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..aec9973 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/docs/API_CONTRACT_NOTES.md b/docs/API_CONTRACT_NOTES.md new file mode 100644 index 0000000..eda8c74 --- /dev/null +++ b/docs/API_CONTRACT_NOTES.md @@ -0,0 +1,158 @@ +# App API Contract Notes + +This is a handoff snapshot, not the product SSOT. + +Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03. + +For full API/data details, read `../report-notebooklm-api/docs/API_AND_DATA.md` from the monorepo root. + +## Configuration + +The App reads the API base URL from: + +```text +RNB_API_BASE +``` + +If `RNB_API_BASE` is missing, live API requests throw an error. This is intentional to avoid silently pointing production builds at a debug backend. + +## Endpoints Currently Consumed + +| Method | Path | App use | +|---|---|---| +| `GET` | `/feed/recommended` | Recommended feed cards. | +| `GET` | `/reports` | Report list. | +| `GET` | `/reports/{report_id}` | Report detail skeleton and modules. | +| `GET` | `/reports/{report_id}/modules/{module_id}` | Module detail page content. | +| `GET` | `/institutions` | Institution list. | +| `GET` | `/institutions/{institution_id}` | Institution detail and recent reports. | +| `GET` | `/listen` | Listen list and mini-player entry. | + +## Response Fields Currently Used + +Report cards: + +- `report_id` +- `title_cn` +- `subtitle_cn` +- `one_liner` +- `institution` +- `topics` +- `released_at` +- `has_audio` +- `interpretation_label` +- `source_tier` +- `cache_version` + +Report detail: + +- `report_id` +- `title_cn` +- `subtitle_cn` +- `original_title` +- `one_liner` +- `institution` +- `source` +- `topics` +- `has_audio` +- `interpretation_label` +- `risk_disclaimer` +- `released_at` +- `cache_version` +- `modules` + +Display modules: + +- `module_id` +- `type` +- `layer` +- `render_mode` +- `has_detail_page` +- `is_publish_blocking` +- `requires_human_review` +- `sort_order` +- `title_cn` +- `content` +- `preview` +- `content_ref` +- `content_etag` + +Listen items: + +- `audio_id` +- `title_cn` +- `duration_sec` +- `report_id` +- `report_title_cn` +- `institution` +- `released_at` +- `cache_version` + +Institution: + +- `institution_id` +- `name_cn` +- `name_en` +- `institution_type` +- `source_tier` +- `website_url` +- `covered_topics` +- `report_count` +- `latest_report_at` +- `credibility_note` +- `intro_cn` +- `latest_report` +- `recent_reports` + +## Module Rendering Behavior + +- `render_mode=inline`: App renders `content` on the report detail page. +- `render_mode=card_plus_page`: App renders `preview` on the report detail page and opens module detail for full content. +- Unknown module `type` should not crash the App; it should fall back to a generic or hidden renderer. + +Current renderer coverage includes: + +- `basic_info` +- `executive_overview` +- `core_insights` +- `key_data` +- `source_compliance` +- `audio` +- `institution` +- `timeline` +- `study_guide` +- `structure_graph` +- `related_sources` +- `differentiated_view` +- `weaknesses` + +## Endpoints Not Yet Consumed + +- `GET /audio/{audio_id}/stream` +- Auth endpoints. +- `/me` personal-state endpoints. +- `POST /outbound/events` + +The UI currently uses placeholders for these blocked flows: + +- Login prompt. +- Favorite action. +- Browse/history sync. +- Saved listen. +- Playback progress. +- Outbound confirmation. +- Real audio playback. + +## Public Field Boundary + +The App must not rely on or display: + +- `display_version` +- module `version` +- raw artifact payload +- raw NotebookLM IDs +- private object-storage references +- local paths +- auth internals + +The App should use `cache_version` as the public cache/version signal. diff --git a/docs/APP_RUNBOOK.md b/docs/APP_RUNBOOK.md new file mode 100644 index 0000000..b52fc26 --- /dev/null +++ b/docs/APP_RUNBOOK.md @@ -0,0 +1,81 @@ +# App Runbook + +This is a handoff snapshot, not the product SSOT. + +Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03. + +## Requirements + +- Flutter 3.44.1 / Dart 3.12.1 or compatible newer versions. +- A backend API serving `/api/report-notebooklm/v1`. +- Android SDK and accepted licenses for Android builds. +- Chrome for web debug. + +Check tool versions: + +```bash +flutter --version +dart --version +flutter doctor -v +``` + +If dependency solving reports that Dart is older than required, switch to a Flutter SDK that includes Dart 3.12.1 or newer. + +## API Base URL + +The App requires an explicit API base URL: + +```bash +--dart-define=RNB_API_BASE= +``` + +Examples: + +```bash +RNB_API_BASE=http://:/api/report-notebooklm/v1 +RNB_API_BASE=https:///api/report-notebooklm/v1 +``` + +Use cleartext HTTP only for local debug builds. Release builds must use HTTPS. + +## Web Run + +```bash +flutter run -d chrome --dart-define=RNB_API_BASE= +``` + +## Android Emulator Run + +Start the backend on the host, then: + +```bash +flutter devices +flutter run -d --dart-define=RNB_API_BASE= +``` + +## Same-Network Android Device + +Start the backend on a host/port reachable from the device, then: + +```bash +flutter run -d --dart-define=RNB_API_BASE=http://:/api/report-notebooklm/v1 +``` + +Use this only for debug. Do not hardcode LAN IPs in source code. + +## Verify + +```bash +flutter analyze +flutter test +flutter build web --dart-define=RNB_API_BASE= +flutter build apk --debug --dart-define=RNB_API_BASE= +``` + +## Release Notes + +- Release builds must not allow cleartext traffic. +- Production API base must be passed explicitly. +- Release signing is not finalized in this scaffold. +- Final app icon and store metadata are not finalized. +- Do not commit build outputs, APKs, screenshots, `.dart_tool/`, or local IDE caches. diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md new file mode 100644 index 0000000..362acee --- /dev/null +++ b/docs/HANDOFF.md @@ -0,0 +1,79 @@ +# App Handoff + +This is a handoff snapshot, not the product SSOT. + +Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03. + +## Current State + +The App is a runnable Phase 1 Flutter shell connected to the backend public seed API. It is not production-ready yet. + +Implemented: + +- Five bottom tabs: 推荐, 研报, 机构, 听单, 我的. +- API client configured by `RNB_API_BASE`. +- API-backed recommended feed, report list, institution list, institution detail, listen list, report detail, and module detail. +- Report detail module renderer registry. +- Inline module rendering and card-plus-page module preview/detail flow. +- Local mini-player UI and player controls. +- Login, favorite, outbound, and playback placeholders that make blocked Phase 1 flows visible without pretending they are complete. +- Android platform scaffold and debug/release build compatibility. + +Not implemented: + +- Auth and real login state. +- Server-synced favorites, reading history, saved listens, and playback progress. +- Real audio stream from `/audio/{audio_id}/stream`. +- Outbound event write before external navigation. +- Production API domain. +- Release signing. +- Final app icon, final brand visuals, privacy copy, and store metadata. + +## Important Product Decisions Reflected in App + +- User-facing product name is `研听`. +- Technical identifiers stay `report-notebooklm` / `rnb`. +- Phase 1 has no download feature for report interpretation content, audio packages, or PDFs. +- Source access is through source/compliance or outbound surfaces, not in-product downloads. +- Guest users can browse public content and listen; login is only required for synchronized personal state. +- App must not call NotebookLM or any LLM to generate report content. + +## Source Tree Map + +| Path | Purpose | +|---|---| +| `lib/main.dart` | App entry point and API data source construction. | +| `lib/app.dart` | Material app and theme hookup. | +| `lib/data/api/` | Backend API client. | +| `lib/data/models/` | Typed response models. | +| `lib/features/shell_page.dart` | Bottom-tab shell and mini-player state. | +| `lib/features/feed/` | Recommended feed. | +| `lib/features/reports/` | Report list. | +| `lib/features/institutions/` | Institution list and detail. | +| `lib/features/listen/` | Listen list. | +| `lib/features/profile/` | Guest/login placeholder and personal-state entry points. | +| `lib/features/detail/` | Report detail. | +| `lib/features/detail/modules/` | Module renderer registry and module detail page. | +| `lib/widgets/` | Reusable UI components. | +| `lib/theme/` | Design tokens and Flutter theme. | +| `android/` | Android platform scaffold. | +| `test/` | Widget tests. | + +## Suggested Handoff Order + +1. Read `docs/PROJECT_BRIEF.md`. +2. Run the backend seed API from `report-notebooklm-api/`. +3. Read `docs/API_CONTRACT_NOTES.md`. +4. Run `flutter analyze` and `flutter test`. +5. Run the App against the backend with `RNB_API_BASE`. +6. Choose the next App/API integration item from the open gaps. + +## Next App Work + +- Wire real auth once backend auth exists. +- Replace favorite/history/saved-listen placeholders with API-backed state. +- Use backend audio stream endpoint for actual playback URL. +- Persist playback progress for logged-in users. +- Write outbound event before external navigation. +- Add production API config and release signing. +- Finalize app icon, privacy wording, store copy, and risk-disclaimer surfaces. diff --git a/docs/PROJECT_BRIEF.md b/docs/PROJECT_BRIEF.md new file mode 100644 index 0000000..1182cd5 --- /dev/null +++ b/docs/PROJECT_BRIEF.md @@ -0,0 +1,65 @@ +# Project Brief Snapshot + +This is a handoff snapshot, not the product SSOT. + +Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03. + +## Product + +`研听` is a Chinese research-report interpretation app for users who want to understand global institutional research with lower language and time barriers. It turns hard-to-read English research reports into structured Chinese reading and listening experiences. + +Technical identifiers remain `report-notebooklm` and `rnb`. Do not use the product display name in code identifiers, database schema names, Redis keys, object storage paths, or API prefixes. + +## Phase 1 Goals + +- Validate whether Chinese users will repeatedly consume global institutional research-report interpretations. +- Ship a complete first app experience for discovery, reading, listening, saving, and returning to reports. +- Establish a minimum loop from report sources to selection, NotebookLM-assisted interpretation, review, storage, API distribution, and app display. +- Keep source attribution and compliance clear: this is report interpretation and annotation, not investment advice. +- Keep the commercial app independent from any local-only Vision runtime. + +## Target Users + +- General Chinese users interested in macro, precious metals, commodities, energy, central banks, and cross-asset research. +- Light professional users who want overseas institutional views and original-source traceability, without trading advice. +- Commuting or fragmented-time users who want reports transformed into listenable content. + +Non-target users: professional terminal users, real-time trading-signal users, UGC/community users, and users expecting original investment recommendations. + +## Main Tabs + +| Tab | Phase 1 scope | Explicitly out of scope | +|---|---|---| +| 推荐 | Latest and curated report interpretations. | Ads, hard trading CTAs, real-time news flashes. | +| 研报 | All published report interpretations with basic filters. | Advanced investment terminal search. | +| 机构 | Institution list and institution report entry points. | Commercial institution ranking or onboarding backend. | +| 听单 | Reports that have audio form. | User-created podcasts, downloads, offline packages. | +| 我的 | Guest/login state, favorites, history, saved listening entry points. | Comments, UGC, paid membership, points. | + +## Phase 1 Must Do + +- Public browsing for recommended reports, report list, institutions, and listen list. +- Report detail pages with title, institution, publication/release data, source type, topics, summary, structured modules, source/compliance information, and favorite entry. +- Guest users can browse public content and fully listen to at least one episode. +- Logged-in users can synchronize favorites, reading history, saved listens, and playback progress. +- Published app responses must expose only reviewed display artifacts, not raw NotebookLM artifacts. +- Every report detail must preserve source attribution and risk disclaimer wording. + +## Phase 1 Must Not Do + +- No commercialization: no ads, paid unlock, membership, task wall, or points. +- No comments, community, UGC, or user-generated report interpretations. +- No investment advice, trading signals, buy/sell points, return promises, or portfolio recommendations. +- No original financial news, real-time reporting, or commentary positioned as original market views. +- No in-product downloads for interpretation content, audio packages, or PDFs. +- No long-term production dependency on a local Vision runtime, local path, local cache, or local account state. +- No App or server-side LLM rewriting of NotebookLM-native content into unsupported original copy. + +## Compliance Boundary + +- Positioning: research-report interpretation and annotation service. +- Content: Chinese interpretation of public or authorized institutional reports. +- Detail pages, agreements, and store metadata must state that content is not investment advice. +- Each item must show institution, source, publication time, and interpretation/source labels. +- Gray broker sources require special handling and human review before public release. +- Phase 1 does not open user content surfaces. diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md new file mode 100644 index 0000000..237df87 --- /dev/null +++ b/docs/PROJECT_MAP.md @@ -0,0 +1,75 @@ +# App Project Map + +This is a handoff snapshot, not the product SSOT. + +Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03. + +## Entry Points + +| Path | Purpose | +|---|---| +| `lib/main.dart` | Creates `RnbApiDataSource` and starts the App. | +| `lib/app.dart` | Defines `MaterialApp`, app title, theme, and shell page. | +| `lib/features/shell_page.dart` | Owns bottom navigation and mini-player state. | + +## Data Layer + +| Path | Purpose | +|---|---| +| `lib/data/api/report_data_source.dart` | API client interface and HTTP implementation. | +| `lib/data/models/models.dart` | Data models, JSON parsing helpers, and formatting helpers. | + +The data source currently performs GET requests only. Mutating APIs for auth, favorites, playback progress, and outbound events are not implemented. + +## Feature Pages + +| Path | Purpose | +|---|---| +| `lib/features/feed/feed_page.dart` | Recommended feed. | +| `lib/features/reports/reports_page.dart` | Report list and simple filters. | +| `lib/features/institutions/institutions_page.dart` | Institution list. | +| `lib/features/institutions/institution_detail_page.dart` | Institution detail and recent reports. | +| `lib/features/listen/listen_page.dart` | Listen list. | +| `lib/features/profile/profile_page.dart` | Guest/login placeholder and personal-state entries. | +| `lib/features/detail/report_detail_page.dart` | Report detail shell and source/compliance display. | +| `lib/features/detail/modules/renderer_registry.dart` | Module card rendering and module detail page rendering. | +| `lib/features/shared/report_card_widget.dart` | Shared report card. | + +## Shared UI + +| Path | Purpose | +|---|---| +| `lib/widgets/app_buttons.dart` | Buttons. | +| `lib/widgets/app_card.dart` | Cards. | +| `lib/widgets/badges.dart` | Badges and chips. | +| `lib/widgets/mini_player.dart` | Mini-player and placeholder player UI. | +| `lib/widgets/sheets.dart` | Login/outbound sheets and toasts. | +| `lib/widgets/states.dart` | Loading, skeleton, empty, and error states. | +| `lib/theme/app_theme.dart` | Flutter `ThemeData`. | +| `lib/theme/wise_tokens.dart` | Color, spacing, radius, motion, and shadow tokens. | + +## Platform + +| Path | Purpose | +|---|---| +| `android/` | Android scaffold. | +| `android/app/src/debug/AndroidManifest.xml` | Debug-only cleartext allowance. | +| `android/app/src/main/AndroidManifest.xml` | Main manifest; release should remain HTTPS-only. | +| `web/` | Flutter web scaffold. | + +## Tests + +| Path | Purpose | +|---|---| +| `test/widget_test.dart` | Smoke widget test with fake data source. | + +## Generated or Local Files + +Do not commit: + +- `build/` +- `.dart_tool/` +- APKs +- screenshots +- local IDE caches +- local environment files diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 0000000..bc3a79c --- /dev/null +++ b/lib/app.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; + +import 'data/api/report_data_source.dart'; +import 'features/shell_page.dart'; +import 'theme/app_theme.dart'; + +class MyApp extends StatelessWidget { + const MyApp({required this.dataSource, super.key}); + + final ReportDataSource dataSource; + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: '研听', + debugShowCheckedModeBanner: false, + theme: buildAppTheme(), + scrollBehavior: const WhitespaceStretchScrollBehavior(), + home: ShellPage(dataSource: dataSource), + ); + } +} + +class WhitespaceStretchScrollBehavior extends MaterialScrollBehavior { + const WhitespaceStretchScrollBehavior(); + + @override + Widget buildOverscrollIndicator( + BuildContext context, + Widget child, + ScrollableDetails details, + ) { + return _WhitespaceStretchIndicator(child: child); + } +} + +class _WhitespaceStretchIndicator extends StatefulWidget { + const _WhitespaceStretchIndicator({required this.child}); + + final Widget child; + + @override + State<_WhitespaceStretchIndicator> createState() => + _WhitespaceStretchIndicatorState(); +} + +class _WhitespaceStretchIndicatorState + extends State<_WhitespaceStretchIndicator> + with SingleTickerProviderStateMixin { + static const double _maxStretch = 64; + static const double _dragResistance = 0.38; + + late final AnimationController _offsetController = + AnimationController.unbounded(vsync: this)..addListener(_onTick); + + @override + Widget build(BuildContext context) { + return NotificationListener( + onNotification: _handleScrollNotification, + child: ClipRect( + child: Transform.translate( + offset: Offset(0, _offsetController.value), + child: widget.child, + ), + ), + ); + } + + @override + void dispose() { + _offsetController.dispose(); + super.dispose(); + } + + bool _handleScrollNotification(ScrollNotification notification) { + if (notification.metrics.axis != Axis.vertical) { + return false; + } + if (notification is OverscrollNotification) { + final overscroll = notification.overscroll; + final atTop = + notification.metrics.pixels <= notification.metrics.minScrollExtent; + final atBottom = + notification.metrics.pixels >= notification.metrics.maxScrollExtent; + if (atTop && overscroll < 0) { + _setOffset( + (_offsetController.value - overscroll * _dragResistance).clamp( + 0, + _maxStretch, + ), + ); + } else if (atBottom && overscroll > 0) { + _setOffset( + (_offsetController.value - overscroll * _dragResistance).clamp( + -_maxStretch, + 0, + ), + ); + } + } + if (notification is ScrollUpdateNotification && + notification.dragDetails == null) { + _releaseOffset(); + } + if (notification is ScrollEndNotification) { + _releaseOffset(); + } + return false; + } + + void _setOffset(num next) { + if (next == _offsetController.value) { + return; + } + _offsetController.stop(); + _offsetController.value = next.toDouble(); + } + + void _releaseOffset() { + if (_offsetController.value == 0) { + return; + } + _offsetController.animateTo( + 0, + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ); + } + + void _onTick() { + if (mounted) { + setState(() {}); + } + } +} diff --git a/lib/data/api/report_data_source.dart b/lib/data/api/report_data_source.dart new file mode 100644 index 0000000..6953024 --- /dev/null +++ b/lib/data/api/report_data_source.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../models/models.dart'; + +abstract class ReportDataSource { + Future> recommended(); + Future> reports(); + Future> institutions(); + Future institutionDetail(String institutionId); + Future> listen(); + Future reportDetail(String reportId); + Future moduleDetail(String reportId, String moduleId); +} + +class RnbApiDataSource implements ReportDataSource { + RnbApiDataSource({ + http.Client? client, + this.baseUrl = const String.fromEnvironment('RNB_API_BASE'), + }) : client = client ?? http.Client(); + + final http.Client client; + final String baseUrl; + + Future _get(String path) async { + if (baseUrl.isEmpty) { + throw StateError('RNB_API_BASE is required for live API requests.'); + } + final response = await client.get(Uri.parse('$baseUrl$path')); + if (response.statusCode != 200) { + throw StateError('Request failed: ${response.statusCode}'); + } + return jsonDecode(utf8.decode(response.bodyBytes)) as JsonMap; + } + + List _items(JsonMap body) => asMapList(body['items']); + + @override + Future> recommended() async { + final body = await _get('/feed/recommended'); + return _items(body).map(ReportCardModel.fromJson).toList(); + } + + @override + Future> reports() async { + final body = await _get('/reports'); + return _items(body).map(ReportCardModel.fromJson).toList(); + } + + @override + Future> institutions() async { + final body = await _get('/institutions'); + return _items(body).map(Institution.fromJson).toList(); + } + + @override + Future institutionDetail(String institutionId) async { + return Institution.fromJson(await _get('/institutions/$institutionId')); + } + + @override + Future> listen() async { + final body = await _get('/listen'); + return _items(body).map(AudioItem.fromJson).toList(); + } + + @override + Future reportDetail(String reportId) async { + return ReportDetail.fromJson(await _get('/reports/$reportId')); + } + + @override + Future moduleDetail(String reportId, String moduleId) async { + return ModuleDetail.fromJson(await _get('/reports/$reportId/modules/$moduleId')); + } +} diff --git a/lib/data/models/models.dart b/lib/data/models/models.dart new file mode 100644 index 0000000..bafbaa5 --- /dev/null +++ b/lib/data/models/models.dart @@ -0,0 +1,313 @@ +typedef JsonMap = Map; + +String asString(Object? value, [String fallback = '']) => + value == null ? fallback : value.toString(); + +int asInt(Object? value, [int fallback = 0]) { + if (value is int) return value; + if (value is num) return value.round(); + return int.tryParse(asString(value)) ?? fallback; +} + +bool asBool(Object? value) => value == true; + +List asStringList(Object? value) { + if (value is List) return value.map((item) => item.toString()).toList(); + return const []; +} + +JsonMap asMap(Object? value) { + if (value is Map) return value; + if (value is Map) return value.map((key, val) => MapEntry(key.toString(), val)); + return const {}; +} + +List asMapList(Object? value) { + if (value is List) return value.map(asMap).where((item) => item.isNotEmpty).toList(); + return const []; +} + +String formatDate(String? iso) { + if (iso == null || iso.isEmpty) return ''; + final parsed = DateTime.tryParse(iso); + if (parsed == null) return iso; + return '${parsed.year}年${parsed.month}月${parsed.day}日'; +} + +String formatDuration(int seconds) { + final safe = seconds < 0 ? 0 : seconds; + final minutes = safe ~/ 60; + final secs = safe % 60; + return '$minutes:${secs.toString().padLeft(2, '0')}'; +} + +class Institution { + const Institution({ + required this.id, + required this.nameCn, + this.nameEn = '', + this.institutionType = '', + this.sourceTier = '', + this.websiteUrl = '', + this.coveredTopics = const [], + this.reportCount = 0, + this.latestReportAt, + this.credibilityNote = '', + this.introCn = '', + this.recentReports = const [], + }); + + final String id; + final String nameCn; + final String nameEn; + final String institutionType; + final String sourceTier; + final String websiteUrl; + final List coveredTopics; + final int reportCount; + final String? latestReportAt; + final String credibilityNote; + final String introCn; + final List recentReports; + + factory Institution.fromJson(JsonMap json) { + return Institution( + id: asString(json['institution_id']), + nameCn: asString(json['name_cn']), + nameEn: asString(json['name_en']), + institutionType: asString(json['institution_type']), + sourceTier: asString(json['source_tier']), + websiteUrl: asString(json['website_url']), + coveredTopics: asStringList(json['covered_topics']), + reportCount: asInt(json['report_count']), + latestReportAt: json['latest_report_at']?.toString(), + credibilityNote: asString(json['credibility_note']), + introCn: asString(json['intro_cn']), + recentReports: asMapList(json['recent_reports']) + .map(ReportCardModel.fromJson) + .toList(), + ); + } +} + +class ReportCardModel { + const ReportCardModel({ + required this.id, + required this.titleCn, + required this.institution, + this.subtitleCn = '', + this.oneLiner = '', + this.topics = const [], + this.releasedAt, + this.hasAudio = false, + this.interpretationLabel = '研报解读', + this.sourceTier = '', + this.cacheVersion = '', + }); + + final String id; + final String titleCn; + final String subtitleCn; + final String oneLiner; + final Institution institution; + final List topics; + final String? releasedAt; + final bool hasAudio; + final String interpretationLabel; + final String sourceTier; + final String cacheVersion; + + factory ReportCardModel.fromJson(JsonMap json) { + final institution = Institution.fromJson(asMap(json['institution'])); + return ReportCardModel( + id: asString(json['report_id']), + titleCn: asString(json['title_cn']), + subtitleCn: asString(json['subtitle_cn']), + oneLiner: asString(json['one_liner']), + institution: institution, + topics: asStringList(json['topics']), + releasedAt: json['released_at']?.toString(), + hasAudio: asBool(json['has_audio']), + interpretationLabel: asString(json['interpretation_label'], '研报解读'), + sourceTier: asString(json['source_tier']), + cacheVersion: asString(json['cache_version']), + ); + } +} + +class AudioItem { + const AudioItem({ + required this.audioId, + required this.reportId, + required this.titleCn, + required this.reportTitleCn, + required this.durationSec, + required this.institution, + this.releasedAt, + this.cacheVersion = '', + }); + + final String audioId; + final String reportId; + final String titleCn; + final String reportTitleCn; + final int durationSec; + final Institution institution; + final String? releasedAt; + final String cacheVersion; + + factory AudioItem.fromJson(JsonMap json) { + return AudioItem( + audioId: asString(json['audio_id']), + reportId: asString(json['report_id']), + titleCn: asString(json['title_cn']), + reportTitleCn: asString(json['report_title_cn'], asString(json['title_cn'])), + durationSec: asInt(json['duration_sec']), + institution: Institution.fromJson(asMap(json['institution'])), + releasedAt: json['released_at']?.toString(), + cacheVersion: asString(json['cache_version']), + ); + } +} + +class DisplayModule { + const DisplayModule({ + required this.id, + required this.type, + required this.titleCn, + required this.renderMode, + this.layer = '', + this.hasDetailPage = false, + this.sortOrder = 0, + this.content = const {}, + this.preview = const {}, + this.contentRef = '', + this.contentEtag = '', + }); + + final String id; + final String type; + final String titleCn; + final String layer; + final String renderMode; + final bool hasDetailPage; + final int sortOrder; + final JsonMap content; + final JsonMap preview; + final String contentRef; + final String contentEtag; + + factory DisplayModule.fromJson(JsonMap json) { + return DisplayModule( + id: asString(json['module_id']), + type: asString(json['type']), + titleCn: asString(json['title_cn'], asString(json['type'])), + layer: asString(json['layer']), + renderMode: asString(json['render_mode'], 'inline'), + hasDetailPage: asBool(json['has_detail_page']), + sortOrder: asInt(json['sort_order']), + content: asMap(json['content']), + preview: asMap(json['preview']), + contentRef: asString(json['content_ref']), + contentEtag: asString(json['content_etag']), + ); + } +} + +class ReportDetail { + const ReportDetail({ + required this.id, + required this.titleCn, + required this.institution, + this.subtitleCn = '', + this.originalTitle = '', + this.oneLiner = '', + this.source = const {}, + this.topics = const [], + this.hasAudio = false, + this.interpretationLabel = '研报解读', + this.riskDisclaimer = '', + this.releasedAt, + this.cacheVersion = '', + this.modules = const [], + }); + + final String id; + final String titleCn; + final String subtitleCn; + final String originalTitle; + final String oneLiner; + final Institution institution; + final JsonMap source; + final List topics; + final bool hasAudio; + final String interpretationLabel; + final String riskDisclaimer; + final String? releasedAt; + final String cacheVersion; + final List modules; + + factory ReportDetail.fromJson(JsonMap json) { + return ReportDetail( + id: asString(json['report_id']), + titleCn: asString(json['title_cn']), + subtitleCn: asString(json['subtitle_cn']), + originalTitle: asString(json['original_title']), + oneLiner: asString(json['one_liner']), + institution: Institution.fromJson(asMap(json['institution'])), + source: asMap(json['source']), + topics: asStringList(json['topics']), + hasAudio: asBool(json['has_audio']), + interpretationLabel: asString(json['interpretation_label'], '研报解读'), + riskDisclaimer: asString(json['risk_disclaimer']), + releasedAt: json['released_at']?.toString(), + cacheVersion: asString(json['cache_version']), + modules: asMapList(json['modules']).map(DisplayModule.fromJson).toList(), + ); + } + + ReportCardModel asCard() { + return ReportCardModel( + id: id, + titleCn: titleCn, + subtitleCn: subtitleCn, + oneLiner: oneLiner, + institution: institution, + topics: topics, + releasedAt: releasedAt, + hasAudio: hasAudio, + interpretationLabel: interpretationLabel, + sourceTier: asString(source['source_tier']), + cacheVersion: cacheVersion, + ); + } +} + +class ModuleDetail { + const ModuleDetail({ + required this.id, + required this.type, + required this.titleCn, + this.content = const {}, + this.contentEtag = '', + this.cacheVersion = '', + }); + + final String id; + final String type; + final String titleCn; + final JsonMap content; + final String contentEtag; + final String cacheVersion; + + factory ModuleDetail.fromJson(JsonMap json) { + return ModuleDetail( + id: asString(json['module_id']), + type: asString(json['type']), + titleCn: asString(json['title_cn'], asString(json['type'])), + content: asMap(json['content']), + contentEtag: asString(json['content_etag']), + cacheVersion: asString(json['cache_version']), + ); + } +} diff --git a/lib/features/detail/modules/renderer_registry.dart b/lib/features/detail/modules/renderer_registry.dart new file mode 100644 index 0000000..b988dd1 --- /dev/null +++ b/lib/features/detail/modules/renderer_registry.dart @@ -0,0 +1,993 @@ +import 'package:flutter/material.dart'; + +import '../../../data/api/report_data_source.dart'; +import '../../../data/models/models.dart'; +import '../../../theme/wise_tokens.dart'; +import '../../../widgets/app_card.dart'; +import '../../../widgets/badges.dart'; +import '../../../widgets/mini_player.dart'; + +typedef StartModuleAudio = + void Function( + String audioId, + String reportId, + String title, + int durationSec, + ); + +class ModuleRendererRegistry { + const ModuleRendererRegistry(); + + Widget card({ + required BuildContext context, + required DisplayModule module, + required ReportDetail report, + required ReportDataSource dataSource, + required PlayerStateModel player, + StartModuleAudio? onStartAudio, + VoidCallback? onToggleAudio, + void Function(int delta)? onSeekAudio, + VoidCallback? onSpeed, + }) { + final openDetail = module.hasDetailPage + ? () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ModuleDetailPage( + reportId: report.id, + module: module, + report: report, + dataSource: dataSource, + registry: this, + ), + ), + ) + : null; + return AppCard( + onTap: openDetail, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ModuleHeader(module: module), + const SizedBox(height: WiseSpacing.x4), + _contentFor( + context, + type: module.type, + payload: module.renderMode == 'inline' + ? module.content + : module.preview, + report: report, + player: player, + onStartAudio: onStartAudio, + onToggleAudio: onToggleAudio, + onSeekAudio: onSeekAudio, + onSpeed: onSpeed, + compact: module.renderMode != 'inline', + ), + if (module.hasDetailPage) ...[ + const SizedBox(height: WiseSpacing.x4), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: openDetail, + icon: const Icon(Icons.open_in_new), + label: const Text('查看详情'), + ), + ), + ], + ], + ), + ); + } + + Widget page( + BuildContext context, + String type, + JsonMap payload, { + ReportDetail? report, + }) { + return _contentFor( + context, + type: type, + payload: payload, + report: report, + player: const PlayerStateModel(), + compact: false, + ); + } + + Widget _contentFor( + BuildContext context, { + required String type, + required JsonMap payload, + required PlayerStateModel player, + ReportDetail? report, + StartModuleAudio? onStartAudio, + VoidCallback? onToggleAudio, + void Function(int delta)? onSeekAudio, + VoidCallback? onSpeed, + bool compact = false, + }) { + return switch (type) { + 'basic_info' => _BasicInfo(payload: payload, report: report), + 'core_insights' => _CoreInsights(payload: payload), + 'source_compliance' => _SourceCompliance( + payload: payload, + report: report, + ), + 'audio' => _AudioModule( + payload: payload, + report: report, + player: player, + onStartAudio: onStartAudio, + onToggleAudio: onToggleAudio, + onSeekAudio: onSeekAudio, + onSpeed: onSpeed, + ), + 'institution' => _InstitutionModule(payload: payload, report: report), + 'executive_overview' => _SectionsModule( + payload: payload, + compact: compact, + ), + 'key_data' => _KeyDataModule(payload: payload, compact: compact), + 'timeline' => _TimelineModule(payload: payload, compact: compact), + 'study_guide' => _StudyGuideModule(payload: payload, compact: compact), + 'structure_graph' => _StructureGraphModule( + payload: payload, + compact: compact, + ), + 'related_sources' => _RelatedSourcesModule( + payload: payload, + compact: compact, + ), + 'differentiated_view' => _DifferentiatedViewModule( + payload: payload, + compact: compact, + ), + 'weaknesses' => _WeaknessesModule(payload: payload, compact: compact), + 'infographic' => _FallbackModule(type: '信息图', payload: payload), + 'research_discovery' => _FallbackModule(type: '延伸研究', payload: payload), + _ => _FallbackModule(type: type, payload: payload), + }; + } +} + +class ModuleDetailPage extends StatefulWidget { + const ModuleDetailPage({ + required this.reportId, + required this.module, + required this.report, + required this.dataSource, + required this.registry, + super.key, + }); + + final String reportId; + final DisplayModule module; + final ReportDetail report; + final ReportDataSource dataSource; + final ModuleRendererRegistry registry; + + @override + State createState() => _ModuleDetailPageState(); +} + +class _ModuleDetailPageState extends State { + late Future future = widget.dataSource.moduleDetail( + widget.reportId, + widget.module.id, + ); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(widget.module.titleCn)), + body: FutureBuilder( + future: future, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center( + child: Text( + snapshot.error.toString(), + textAlign: TextAlign.center, + ), + ); + } + final detail = snapshot.data!; + return ListView( + padding: const EdgeInsets.all(WiseSpacing.x4), + children: [ + AppCard( + child: widget.registry.page( + context, + detail.type, + detail.content, + report: widget.report, + ), + ), + const SizedBox(height: WiseSpacing.x3), + Text( + '缓存版本 ${detail.cacheVersion}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ); + }, + ), + ); + } +} + +class _ModuleHeader extends StatelessWidget { + const _ModuleHeader({required this.module}); + + final DisplayModule module; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Text( + module.titleCn, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + if (module.layer.isNotEmpty) + AppBadge(text: module.layer.toUpperCase(), kind: BadgeKind.brand), + ], + ); + } +} + +class _BasicInfo extends StatelessWidget { + const _BasicInfo({required this.payload, required this.report}); + + final JsonMap payload; + final ReportDetail? report; + + @override + Widget build(BuildContext context) { + final topics = asStringList(payload['topics']).isEmpty + ? report?.topics ?? const [] + : asStringList(payload['topics']); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + asString( + payload['summary_cn'], + asString(payload['scope_cn'], report?.oneLiner ?? ''), + ), + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: WiseSpacing.x2), + Wrap( + spacing: WiseSpacing.x2, + runSpacing: WiseSpacing.x2, + children: [ + for (final topic in topics) AppBadge(text: topic), + if (report?.releasedAt != null) + AppBadge(text: formatDate(report!.releasedAt)), + ], + ), + ], + ); + } +} + +class _CoreInsights extends StatelessWidget { + const _CoreInsights({required this.payload}); + + final JsonMap payload; + + @override + Widget build(BuildContext context) { + final points = asMapList(payload['points']); + if (points.isEmpty) return _TextLines(payload: payload); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final point in points) + Padding( + padding: const EdgeInsets.only(bottom: WiseSpacing.x3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppBadge( + text: _kindLabel(asString(point['kind'])), + kind: _kindBadge(asString(point['kind'])), + ), + const SizedBox(height: WiseSpacing.x1), + Text( + asString(point['text']), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ], + ); + } +} + +class _SourceCompliance extends StatelessWidget { + const _SourceCompliance({required this.payload, required this.report}); + + final JsonMap payload; + final ReportDetail? report; + + @override + Widget build(BuildContext context) { + final institution = report?.institution; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (asString(payload['source_note']).isNotEmpty) + Text( + asString(payload['source_note']), + style: Theme.of(context).textTheme.bodyMedium, + ), + if (institution != null) ...[ + const SizedBox(height: WiseSpacing.x4), + Text('发布机构', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: WiseSpacing.x2), + _InfoLine(label: '机构名称', value: institution.nameCn), + if (institution.nameEn.isNotEmpty) + _InfoLine(label: '英文名称', value: institution.nameEn), + if (institution.institutionType.isNotEmpty) + _InfoLine( + label: '机构类型', + value: _institutionTypeLabel(institution.institutionType), + ), + if (institution.sourceTier.isNotEmpty) + _InfoLine(label: '来源层级', value: institution.sourceTier), + if (institution.reportCount > 0) + _InfoLine(label: '收录报告', value: '${institution.reportCount} 份'), + if (institution.coveredTopics.isNotEmpty) + _InfoLine( + label: '覆盖主题', + value: institution.coveredTopics.join('、'), + ), + if (institution.websiteUrl.isNotEmpty) + _InfoLine(label: '官网', value: institution.websiteUrl), + if (institution.introCn.isNotEmpty) + _InfoLine(label: '说明', value: institution.introCn), + ], + if (asString(payload['copyright_cn']).isNotEmpty) ...[ + const SizedBox(height: WiseSpacing.x4), + Text( + asString(payload['copyright_cn']), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + const SizedBox(height: WiseSpacing.x3), + DecoratedBox( + decoration: BoxDecoration( + color: const Color(0x109A6500), + borderRadius: BorderRadius.circular(WiseRadius.sm), + ), + child: Padding( + padding: const EdgeInsets.all(WiseSpacing.x3), + child: Text( + asString(payload['disclaimer'], '本内容为公开/授权研报的结构化解读,不构成投资建议。'), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: WiseColors.warning), + ), + ), + ), + ], + ); + } +} + +class _InfoLine extends StatelessWidget { + const _InfoLine({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + if (value.isEmpty) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(bottom: WiseSpacing.x2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: WiseColors.ink700), + ), + const SizedBox(height: WiseSpacing.x1), + Text(value, style: Theme.of(context).textTheme.bodyMedium), + ], + ), + ); + } +} + +class _AudioModule extends StatelessWidget { + const _AudioModule({ + required this.payload, + required this.report, + required this.player, + this.onStartAudio, + this.onToggleAudio, + this.onSeekAudio, + this.onSpeed, + }); + + final JsonMap payload; + final ReportDetail? report; + final PlayerStateModel player; + final StartModuleAudio? onStartAudio; + final VoidCallback? onToggleAudio; + final void Function(int delta)? onSeekAudio; + final VoidCallback? onSpeed; + + @override + Widget build(BuildContext context) { + final title = asString(payload['title_cn'], report?.titleCn ?? '音频解读'); + final audioId = asString( + payload['audio_id'], + 'local_${report?.id ?? title.hashCode}', + ); + final duration = asInt(payload['duration_sec'], 180); + return PlayerCard( + title: title, + durationSec: duration, + player: player, + onStart: () => + onStartAudio?.call(audioId, report?.id ?? '', title, duration), + onToggle: onToggleAudio ?? () {}, + onSeek: onSeekAudio ?? (_) {}, + onSpeed: onSpeed ?? () {}, + ); + } +} + +class _InstitutionModule extends StatelessWidget { + const _InstitutionModule({required this.payload, required this.report}); + + final JsonMap payload; + final ReportDetail? report; + + @override + Widget build(BuildContext context) { + final name = asString(payload['name_cn'], report?.institution.nameCn ?? ''); + return Row( + children: [ + const Icon(Icons.account_balance_outlined, color: WiseColors.primary), + const SizedBox(width: WiseSpacing.x2), + Expanded( + child: Text(name, style: Theme.of(context).textTheme.bodyMedium), + ), + Text( + '${asInt(payload['report_count'], report?.institution.reportCount ?? 0)} 份', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ); + } +} + +class _SectionsModule extends StatelessWidget { + const _SectionsModule({required this.payload, required this.compact}); + + final JsonMap payload; + final bool compact; + + @override + Widget build(BuildContext context) { + final summary = asString( + payload['preview_summary'], + asString(payload['intro_cn']), + ); + final sections = asMapList(payload['sections']); + if (compact) return _Preview(payload: payload); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (summary.isNotEmpty) + Text(summary, style: Theme.of(context).textTheme.bodyMedium), + for (final section in sections) ...[ + const SizedBox(height: WiseSpacing.x3), + Text( + asString(section['heading']), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: WiseSpacing.x1), + Text( + asString(section['body']), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ], + ); + } +} + +class _KeyDataModule extends StatelessWidget { + const _KeyDataModule({required this.payload, required this.compact}); + + final JsonMap payload; + final bool compact; + + @override + Widget build(BuildContext context) { + if (compact) return _Preview(payload: payload); + final rows = asMapList(payload['rows']); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final row in rows) + Padding( + padding: const EdgeInsets.only(bottom: WiseSpacing.x4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + asString(row['metric']), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: WiseSpacing.x1), + Text( + _valueWithUnit(row), + style: Theme.of(context).textTheme.bodyLarge, + ), + if (asString( + row['judgment'], + asString(row['importance']), + ).isNotEmpty) ...[ + const SizedBox(height: WiseSpacing.x1), + Text( + asString(row['judgment'], asString(row['importance'])), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + if (asString(row['importance']).isNotEmpty && + asString(row['importance']) != + asString(row['judgment'])) ...[ + const SizedBox(height: WiseSpacing.x1), + Text( + asString(row['importance']), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ], + ), + ), + ], + ); + } +} + +class _TimelineModule extends StatelessWidget { + const _TimelineModule({required this.payload, required this.compact}); + + final JsonMap payload; + final bool compact; + + @override + Widget build(BuildContext context) { + if (compact) return _Preview(payload: payload); + final events = asMapList(payload['events']); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final event in events) + Padding( + padding: const EdgeInsets.only(bottom: WiseSpacing.x4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (asString(event['date']).isNotEmpty) + Text( + asString(event['date']), + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: WiseColors.primary), + ), + const SizedBox(height: WiseSpacing.x1), + Text( + asString(event['event']), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: WiseSpacing.x1), + Text( + asString(event['impact']), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ], + ); + } +} + +class _StudyGuideModule extends StatelessWidget { + const _StudyGuideModule({required this.payload, required this.compact}); + + final JsonMap payload; + final bool compact; + + @override + Widget build(BuildContext context) { + if (compact) return _Preview(payload: payload); + final faqs = asMapList(payload['faq_items']); + final glossary = asMapList(payload['glossary']); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (asString(payload['intro_cn']).isNotEmpty) + Text( + asString(payload['intro_cn']), + style: Theme.of(context).textTheme.bodyMedium, + ), + for (final item in faqs) + ExpansionTile( + tilePadding: EdgeInsets.zero, + title: Text(asString(item['question'])), + children: [ + Align( + alignment: Alignment.centerLeft, + child: Text( + asString(item['answer']), + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + if (glossary.isNotEmpty) ...[ + const SizedBox(height: WiseSpacing.x3), + Wrap( + spacing: WiseSpacing.x2, + runSpacing: WiseSpacing.x2, + children: [ + for (final item in glossary) + AppBadge( + text: + '${asString(item['term'])}: ${asString(item['definition'])}', + ), + ], + ), + ], + ], + ); + } +} + +class _StructureGraphModule extends StatelessWidget { + const _StructureGraphModule({required this.payload, required this.compact}); + + final JsonMap payload; + final bool compact; + + @override + Widget build(BuildContext context) { + if (compact) return _Preview(payload: payload); + final nodes = asMapList(payload['nodes']); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + asString(payload['root']), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: WiseSpacing.x3), + for (final node in nodes) + Padding( + padding: const EdgeInsets.only(bottom: WiseSpacing.x4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + asString(node['label']), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: WiseSpacing.x1), + for (final child in asStringList(node['children'])) + Padding( + padding: const EdgeInsets.only(bottom: WiseSpacing.x1), + child: Text( + child, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + ), + ], + ); + } +} + +class _RelatedSourcesModule extends StatelessWidget { + const _RelatedSourcesModule({required this.payload, required this.compact}); + + final JsonMap payload; + final bool compact; + + @override + Widget build(BuildContext context) { + final items = asMapList(payload['items'] ?? payload['sources']); + if (items.isEmpty) return _Preview(payload: payload); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final item in items) + Padding( + padding: const EdgeInsets.only(bottom: WiseSpacing.x4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + asString(item['title']), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: WiseSpacing.x1), + Text( + asString(item['summary_cn'], asString(item['source_name'])), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ], + ); + } +} + +class _DifferentiatedViewModule extends StatelessWidget { + const _DifferentiatedViewModule({ + required this.payload, + required this.compact, + }); + + final JsonMap payload; + final bool compact; + + @override + Widget build(BuildContext context) { + if (compact) return _Preview(payload: payload); + final items = asMapList(payload['divergences']); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final item in items) + Padding( + padding: const EdgeInsets.only(bottom: WiseSpacing.x4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + asString(item['topic']), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: WiseSpacing.x2), + if (asString(item['consensus_view']).isNotEmpty) ...[ + Text( + '常见观点', + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: WiseColors.ink700), + ), + const SizedBox(height: WiseSpacing.x1), + Text( + asString(item['consensus_view']), + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: WiseSpacing.x2), + ], + if (asString(item['report_position']).isNotEmpty) ...[ + Text( + '报告观点', + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: WiseColors.primary), + ), + const SizedBox(height: WiseSpacing.x1), + Text( + asString(item['report_position']), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ], + ), + ), + ], + ); + } +} + +class _WeaknessesModule extends StatelessWidget { + const _WeaknessesModule({required this.payload, required this.compact}); + + final JsonMap payload; + final bool compact; + + @override + Widget build(BuildContext context) { + if (compact) return _Preview(payload: payload); + final items = asMapList(payload['items']); + final verificationNotes = asStringList(payload['verification_notes']); + final counterEvidence = { + for (final item in items) + if (asString(item['counter_evidence']).isNotEmpty) + asString(item['counter_evidence']), + }.toList(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (asString(payload['disclaimer_cn']).isNotEmpty) + Text( + asString(payload['disclaimer_cn']), + style: Theme.of(context).textTheme.bodySmall, + ), + for (final item in items) + Padding( + padding: const EdgeInsets.only( + top: WiseSpacing.x3, + bottom: WiseSpacing.x2, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + asString(item['topic']), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: WiseSpacing.x1), + Text( + asString(item['weakness']), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + if (verificationNotes.isNotEmpty || counterEvidence.isNotEmpty) ...[ + const SizedBox(height: WiseSpacing.x2), + DecoratedBox( + decoration: BoxDecoration( + color: const Color(0x109A6500), + borderRadius: BorderRadius.circular(WiseRadius.sm), + ), + child: Padding( + padding: const EdgeInsets.all(WiseSpacing.x3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '需要继续验证', + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: WiseColors.warning), + ), + const SizedBox(height: WiseSpacing.x1), + for (final note + in verificationNotes.isNotEmpty + ? verificationNotes + : counterEvidence) + Padding( + padding: const EdgeInsets.only(bottom: WiseSpacing.x1), + child: Text( + note, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ), + ), + ], + ], + ); + } +} + +class _Preview extends StatelessWidget { + const _Preview({required this.payload}); + + final JsonMap payload; + + @override + Widget build(BuildContext context) { + final headline = asString( + payload['preview_headline'], + asString(payload['preview_summary'], asString(payload['root'])), + ); + final highlights = asStringList(payload['highlights']); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (headline.isNotEmpty) + Text(headline, style: Theme.of(context).textTheme.bodyMedium), + for (final item in highlights.take(3)) + Padding( + padding: const EdgeInsets.only(top: WiseSpacing.x1), + child: Text( + '• $item', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ); + } +} + +class _TextLines extends StatelessWidget { + const _TextLines({required this.payload}); + + final JsonMap payload; + + @override + Widget build(BuildContext context) { + final values = payload.entries + .where( + (entry) => entry.value != null && entry.value.toString().isNotEmpty, + ) + .map((entry) => '${entry.key}: ${entry.value}') + .take(5) + .join('\n'); + return Text( + values.isEmpty ? '该模块暂无可展示内容。' : values, + style: Theme.of(context).textTheme.bodyMedium, + ); + } +} + +class _FallbackModule extends StatelessWidget { + const _FallbackModule({required this.type, required this.payload}); + + final String type; + final JsonMap payload; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppBadge(text: '未知模块:$type', kind: BadgeKind.warning), + const SizedBox(height: WiseSpacing.x2), + _Preview(payload: payload), + ], + ); + } +} + +String _kindLabel(String kind) => switch (kind) { + 'view' => '观点', + 'number' => '数字', + 'risk' => '风险', + _ => '要点', +}; + +BadgeKind _kindBadge(String kind) => switch (kind) { + 'risk' => BadgeKind.warning, + 'number' => BadgeKind.audio, + _ => BadgeKind.brand, +}; + +String _valueWithUnit(JsonMap row) { + final value = asString(row['value']); + final unit = asString(row['unit']); + if (unit.isEmpty) return value; + return '$value $unit'; +} + +String _institutionTypeLabel(String value) => switch (value) { + 'international_org' => '国际组织', + 'official' => '官方机构', + 'industry_org' => '行业组织', + 'asset_manager' => '资管机构', + 'bank_research' => '银行研究', + 'partner' => '合作机构', + _ => value, +}; diff --git a/lib/features/detail/report_detail_page.dart b/lib/features/detail/report_detail_page.dart new file mode 100644 index 0000000..f173e2c --- /dev/null +++ b/lib/features/detail/report_detail_page.dart @@ -0,0 +1,199 @@ +import 'package:flutter/material.dart'; + +import '../../data/api/report_data_source.dart'; +import '../../data/models/models.dart'; +import '../../theme/wise_tokens.dart'; +import '../../widgets/app_buttons.dart'; +import '../../widgets/app_card.dart'; +import '../../widgets/badges.dart'; +import '../../widgets/mini_player.dart'; +import '../../widgets/sheets.dart'; +import '../../widgets/states.dart'; +import 'modules/renderer_registry.dart'; + +class ReportDetailPage extends StatefulWidget { + const ReportDetailPage({ + required this.reportId, + required this.dataSource, + this.player = const PlayerStateModel(), + this.onStartAudio, + this.onToggleAudio, + this.onSeekAudio, + this.onSpeed, + super.key, + }); + + final String reportId; + final ReportDataSource dataSource; + final PlayerStateModel player; + final void Function( + String audioId, + String reportId, + String title, + int durationSec, + )? + onStartAudio; + final VoidCallback? onToggleAudio; + final void Function(int delta)? onSeekAudio; + final VoidCallback? onSpeed; + + @override + State createState() => _ReportDetailPageState(); +} + +class _ReportDetailPageState extends State { + static const registry = ModuleRendererRegistry(); + late Future future = widget.dataSource.reportDetail( + widget.reportId, + ); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('研报详情')), + body: FutureBuilder( + future: future, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const LoadingState(); + } + if (snapshot.hasError) { + return ErrorState( + message: snapshot.error.toString(), + onRetry: () => setState( + () => future = widget.dataSource.reportDetail(widget.reportId), + ), + ); + } + final detail = snapshot.data!; + return ListView( + padding: const EdgeInsets.all(WiseSpacing.x4), + children: [ + AppCard( + color: WiseColors.secondary200, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: WiseSpacing.x2, + runSpacing: WiseSpacing.x2, + children: [ + AppBadge( + text: detail.interpretationLabel, + kind: BadgeKind.brand, + ), + if (detail.hasAudio) + const AppBadge( + text: '音频', + icon: Icons.graphic_eq, + kind: BadgeKind.audio, + ), + AppBadge( + text: asString(detail.source['source_tier']), + icon: Icons.verified_outlined, + kind: BadgeKind.tier, + ), + ], + ), + const SizedBox(height: WiseSpacing.x3), + Text( + detail.titleCn, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.headlineSmall, + ), + if (detail.oneLiner.isNotEmpty) ...[ + const SizedBox(height: WiseSpacing.x2), + Text( + detail.oneLiner, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: WiseSpacing.x3), + Text( + '${detail.institution.nameCn} · ${formatDate(detail.releasedAt)}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + const SizedBox(height: WiseSpacing.x4), + _ActionBar(detail: detail), + const SizedBox(height: WiseSpacing.x4), + _Toc(modules: detail.modules), + const SizedBox(height: WiseSpacing.x4), + for (final module in detail.modules) ...[ + registry.card( + context: context, + module: module, + report: detail, + dataSource: widget.dataSource, + player: widget.player, + onStartAudio: widget.onStartAudio, + onToggleAudio: widget.onToggleAudio, + onSeekAudio: widget.onSeekAudio, + onSpeed: widget.onSpeed, + ), + const SizedBox(height: WiseSpacing.x4), + ], + ], + ); + }, + ), + ); + } +} + +class _ActionBar extends StatelessWidget { + const _ActionBar({required this.detail}); + + final ReportDetail detail; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: AppButton( + label: '收藏', + icon: Icons.favorite_border, + kind: AppButtonKind.ghost, + onPressed: () => showLoginSheet(context, reason: '登录后保存到你的收藏'), + ), + ), + const SizedBox(width: WiseSpacing.x2), + Expanded( + child: AppButton( + label: '原文', + icon: Icons.open_in_new, + kind: AppButtonKind.ghost, + onPressed: () => showOutboundSheet(context, title: detail.titleCn), + ), + ), + ], + ); + } +} + +class _Toc extends StatelessWidget { + const _Toc({required this.modules}); + + final List modules; + + @override + Widget build(BuildContext context) { + if (modules.isEmpty) return const SizedBox.shrink(); + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final module in modules) + Padding( + padding: const EdgeInsets.only(right: WiseSpacing.x2), + child: AppBadge(text: module.titleCn, kind: BadgeKind.brand), + ), + ], + ), + ); + } +} diff --git a/lib/features/feed/feed_page.dart b/lib/features/feed/feed_page.dart new file mode 100644 index 0000000..7a6ea91 --- /dev/null +++ b/lib/features/feed/feed_page.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; + +import '../../data/api/report_data_source.dart'; +import '../../data/models/models.dart'; +import '../../routing/app_routes.dart'; +import '../../theme/wise_tokens.dart'; +import '../../widgets/badges.dart'; +import '../../widgets/mini_player.dart'; +import '../../widgets/states.dart'; +import '../shared/report_card_widget.dart'; + +class FeedPage extends StatefulWidget { + const FeedPage({ + required this.dataSource, + required this.onPlay, + this.player = const PlayerStateModel(), + this.onStartModuleAudio, + this.onToggleAudio, + this.onSeekAudio, + this.onSpeed, + super.key, + }); + + final ReportDataSource dataSource; + final void Function(AudioItem item) onPlay; + final PlayerStateModel player; + final void Function(String audioId, String reportId, String title, int durationSec)? onStartModuleAudio; + final VoidCallback? onToggleAudio; + final void Function(int delta)? onSeekAudio; + final VoidCallback? onSpeed; + + @override + State createState() => _FeedPageState(); +} + +class _FeedPageState extends State { + String topic = '全部'; + late Future> future = widget.dataSource.recommended(); + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: future, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) return const LoadingState(); + if (snapshot.hasError) return ErrorState(message: snapshot.error.toString(), onRetry: () => setState(() => future = widget.dataSource.recommended())); + final items = snapshot.data ?? const []; + final topics = ['全部', ...{for (final item in items) ...item.topics}]; + final visible = topic == '全部' ? items : items.where((item) => item.topics.contains(topic)).toList(); + if (items.isEmpty) return const EmptyState(title: '暂无可推荐的研报解读', message: '稍后再来看看最新内容'); + return ListView( + padding: const EdgeInsets.all(WiseSpacing.x4), + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final t in topics) + Padding( + padding: const EdgeInsets.only(right: WiseSpacing.x2), + child: AppChip(label: t, selected: t == topic, onTap: () => setState(() => topic = t)), + ), + ], + ), + ), + const SizedBox(height: WiseSpacing.x3), + if (visible.isEmpty) + EmptyState(title: '暂无可推荐的研报解读', message: '换个主题,或去研报页看看全部内容', icon: Icons.filter_alt_off) + else ...[ + ReportCardWidget( + report: visible.first, + hero: true, + onTap: () => openReportDetail( + context, + widget.dataSource, + visible.first, + player: widget.player, + onStartAudio: widget.onStartModuleAudio, + onToggleAudio: widget.onToggleAudio, + onSeekAudio: widget.onSeekAudio, + onSpeed: widget.onSpeed, + ), + onPlayTap: () => playFromReport(widget.onPlay, visible.first), + ), + const SizedBox(height: WiseSpacing.x5), + Text('最新解读', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: WiseSpacing.x3), + for (final report in visible.skip(1)) ...[ + ReportCardWidget( + report: report, + onTap: () => openReportDetail( + context, + widget.dataSource, + report, + player: widget.player, + onStartAudio: widget.onStartModuleAudio, + onToggleAudio: widget.onToggleAudio, + onSeekAudio: widget.onSeekAudio, + onSpeed: widget.onSpeed, + ), + onPlayTap: () => playFromReport(widget.onPlay, report), + ), + const SizedBox(height: WiseSpacing.x3), + ], + ], + ], + ); + }, + ); + } + + void playFromReport(void Function(AudioItem item) onPlay, ReportCardModel report) { + onPlay( + AudioItem( + audioId: 'local_${report.id}', + reportId: report.id, + titleCn: report.titleCn, + reportTitleCn: report.titleCn, + durationSec: 180, + institution: report.institution, + ), + ); + } +} diff --git a/lib/features/institutions/institution_detail_page.dart b/lib/features/institutions/institution_detail_page.dart new file mode 100644 index 0000000..fd7ec78 --- /dev/null +++ b/lib/features/institutions/institution_detail_page.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; + +import '../../data/api/report_data_source.dart'; +import '../../data/models/models.dart'; +import '../../routing/app_routes.dart'; +import '../../theme/wise_tokens.dart'; +import '../../widgets/app_buttons.dart'; +import '../../widgets/app_card.dart'; +import '../../widgets/badges.dart'; +import '../../widgets/sheets.dart'; +import '../../widgets/states.dart'; +import '../shared/report_card_widget.dart'; + +class InstitutionDetailPage extends StatefulWidget { + const InstitutionDetailPage({required this.institutionId, required this.dataSource, super.key}); + + final String institutionId; + final ReportDataSource dataSource; + + @override + State createState() => _InstitutionDetailPageState(); +} + +class _InstitutionDetailPageState extends State { + late Future future = widget.dataSource.institutionDetail(widget.institutionId); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('机构主页')), + body: FutureBuilder( + future: future, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) return const LoadingState(); + if (snapshot.hasError) return ErrorState(message: snapshot.error.toString(), onRetry: () => setState(() => future = widget.dataSource.institutionDetail(widget.institutionId))); + final item = snapshot.data!; + return ListView( + padding: const EdgeInsets.all(WiseSpacing.x4), + children: [ + AppCard( + color: WiseColors.secondary200, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.nameCn, style: Theme.of(context).textTheme.headlineSmall), + if (item.nameEn.isNotEmpty) Text(item.nameEn, style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: WiseSpacing.x3), + Wrap( + spacing: WiseSpacing.x2, + runSpacing: WiseSpacing.x2, + children: [ + AppBadge(text: item.sourceTier, icon: Icons.verified_outlined, kind: BadgeKind.tier), + AppBadge(text: '${item.reportCount} 份研报', kind: BadgeKind.brand), + for (final topic in item.coveredTopics) AppBadge(text: topic), + ], + ), + ], + ), + ), + const SizedBox(height: WiseSpacing.x3), + if (item.introCn.isNotEmpty) + AppCard(child: Text(item.introCn, style: Theme.of(context).textTheme.bodyMedium)), + const SizedBox(height: WiseSpacing.x3), + if (item.credibilityNote.isNotEmpty) + AppCard( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.verified_user_outlined, color: WiseColors.positive), + const SizedBox(width: WiseSpacing.x2), + Expanded(child: Text(item.credibilityNote, style: Theme.of(context).textTheme.bodyMedium)), + ], + ), + ), + const SizedBox(height: WiseSpacing.x5), + Text('最新研报', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: WiseSpacing.x3), + if (item.recentReports.isEmpty) + const EmptyState(title: '机构暂无研报', message: '稍后再试', icon: Icons.article_outlined) + else + for (final report in item.recentReports) ...[ + ReportCardWidget( + report: report, + onTap: () => openReportDetail(context, widget.dataSource, report), + ), + const SizedBox(height: WiseSpacing.x3), + ], + AppButton( + label: '了解相关服务', + icon: Icons.open_in_new, + kind: AppButtonKind.ghost, + expand: true, + onPressed: () => showOutboundSheet(context, title: item.nameCn), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/features/institutions/institutions_page.dart b/lib/features/institutions/institutions_page.dart new file mode 100644 index 0000000..9d160a3 --- /dev/null +++ b/lib/features/institutions/institutions_page.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; + +import '../../data/api/report_data_source.dart'; +import '../../data/models/models.dart'; +import '../../routing/app_routes.dart'; +import '../../theme/wise_tokens.dart'; +import '../../widgets/app_card.dart'; +import '../../widgets/badges.dart'; +import '../../widgets/states.dart'; + +class InstitutionsPage extends StatefulWidget { + const InstitutionsPage({required this.dataSource, super.key}); + + final ReportDataSource dataSource; + + @override + State createState() => _InstitutionsPageState(); +} + +class _InstitutionsPageState extends State { + late Future> future = widget.dataSource.institutions(); + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: future, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) return const LoadingState(); + if (snapshot.hasError) return ErrorState(message: snapshot.error.toString(), onRetry: () => setState(() => future = widget.dataSource.institutions())); + final items = [...snapshot.data ?? const []]..sort((a, b) => b.reportCount.compareTo(a.reportCount)); + if (items.isEmpty) return const EmptyState(title: '暂无机构信息', message: '稍后再试', icon: Icons.account_balance_outlined); + return ListView( + padding: const EdgeInsets.all(WiseSpacing.x4), + children: [ + Text('研报来源机构', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: WiseSpacing.x3), + for (final item in items) ...[ + InstitutionCard( + institution: item, + onTap: () => openInstitutionDetail(context, widget.dataSource, item.id), + ), + const SizedBox(height: WiseSpacing.x3), + ], + ], + ); + }, + ); + } +} + +class InstitutionCard extends StatelessWidget { + const InstitutionCard({required this.institution, required this.onTap, super.key}); + + final Institution institution; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final initials = institution.nameCn.isEmpty ? '研' : institution.nameCn.characters.take(2).toString(); + return AppCard( + onTap: onTap, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 25, + backgroundColor: WiseColors.secondary200, + foregroundColor: WiseColors.primary, + child: Text(initials, style: const TextStyle(fontWeight: FontWeight.w800)), + ), + const SizedBox(width: WiseSpacing.x3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(institution.nameCn, style: Theme.of(context).textTheme.titleMedium), + if (institution.nameEn.isNotEmpty) + Text(institution.nameEn, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall), + const SizedBox(height: WiseSpacing.x2), + Wrap( + spacing: WiseSpacing.x2, + runSpacing: WiseSpacing.x2, + children: [ + if (institution.institutionType.isNotEmpty) AppBadge(text: institution.institutionType), + for (final topic in institution.coveredTopics.take(3)) AppBadge(text: topic, kind: BadgeKind.brand), + ], + ), + ], + ), + ), + const SizedBox(width: WiseSpacing.x2), + Column( + children: [ + Text('${institution.reportCount}', style: Theme.of(context).textTheme.titleMedium?.copyWith(color: WiseColors.primary)), + Text('份研报', style: Theme.of(context).textTheme.bodySmall), + ], + ), + ], + ), + ); + } +} diff --git a/lib/features/listen/listen_page.dart b/lib/features/listen/listen_page.dart new file mode 100644 index 0000000..62ffbbf --- /dev/null +++ b/lib/features/listen/listen_page.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +import '../../data/api/report_data_source.dart'; +import '../../data/models/models.dart'; +import '../../theme/wise_tokens.dart'; +import '../../widgets/app_card.dart'; +import '../../widgets/states.dart'; + +class ListenPage extends StatefulWidget { + const ListenPage({required this.dataSource, required this.onPlay, super.key}); + + final ReportDataSource dataSource; + final void Function(AudioItem item) onPlay; + + @override + State createState() => _ListenPageState(); +} + +class _ListenPageState extends State { + late Future> future = widget.dataSource.listen(); + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: future, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) return const LoadingState(label: '正在加载听单'); + if (snapshot.hasError) return ErrorState(message: snapshot.error.toString(), onRetry: () => setState(() => future = widget.dataSource.listen())); + final items = snapshot.data ?? const []; + if (items.isEmpty) return const EmptyState(title: '暂无音频研报', message: '先去研报页看看图文解读', icon: Icons.headphones_outlined); + return ListView( + padding: const EdgeInsets.all(WiseSpacing.x4), + children: [ + Text('全站音频解读', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: WiseSpacing.x2), + Text('游客可完整收听;真实音频流待后端接入。', style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: WiseSpacing.x4), + for (final item in items) ...[ + AppCard( + onTap: () => widget.onPlay(item), + child: Row( + children: [ + IconButton.filled( + onPressed: () => widget.onPlay(item), + icon: const Icon(Icons.play_arrow), + style: IconButton.styleFrom(backgroundColor: WiseColors.primary, foregroundColor: Colors.white), + ), + const SizedBox(width: WiseSpacing.x3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.reportTitleCn, maxLines: 2, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleMedium), + Text('${item.institution.nameCn} · ${formatDuration(item.durationSec)}', style: Theme.of(context).textTheme.bodySmall), + const SizedBox(height: WiseSpacing.x2), + LinearProgressIndicator(value: 0, minHeight: 4, color: WiseColors.accent, backgroundColor: WiseColors.border), + ], + ), + ), + ], + ), + ), + const SizedBox(height: WiseSpacing.x3), + ], + ], + ); + }, + ); + } +} diff --git a/lib/features/profile/profile_page.dart b/lib/features/profile/profile_page.dart new file mode 100644 index 0000000..b4bb874 --- /dev/null +++ b/lib/features/profile/profile_page.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; + +import '../../data/api/report_data_source.dart'; +import '../../theme/wise_tokens.dart'; +import '../../widgets/app_buttons.dart'; +import '../../widgets/app_card.dart'; +import '../../widgets/sheets.dart'; +import '../../widgets/states.dart'; + +class ProfilePage extends StatelessWidget { + const ProfilePage({required this.dataSource, super.key}); + + final ReportDataSource dataSource; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(WiseSpacing.x4), + children: [ + AppCard( + color: WiseColors.secondary200, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('游客', style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: WiseSpacing.x2), + Text('浏览、阅读和完整收听不需要登录。收藏、历史同步和保存听单等待 auth 接口接入。', style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: WiseSpacing.x4), + AppButton( + label: '登录后保存个人状态', + icon: Icons.login, + onPressed: () => showLoginSheet(context), + ), + ], + ), + ), + const SizedBox(height: WiseSpacing.x4), + _ProfileRow(icon: Icons.favorite_border, title: '收藏研报', subtitle: '登录后同步收藏', onTap: () => showLoginSheet(context, reason: '登录后保存到你的收藏')), + _ProfileRow(icon: Icons.history, title: '浏览历史', subtitle: '本地历史占位,服务端同步待接入', onTap: () => showAppToast(context, '历史同步接口待接入')), + _ProfileRow(icon: Icons.playlist_add_check, title: '保存听单', subtitle: '登录后保存到你的听单', onTap: () => showLoginSheet(context, reason: '登录后保存到你的听单')), + _ProfileRow(icon: Icons.open_in_new, title: '了解研值相关服务', subtitle: '外跳前提示风险边界', onTap: () => showOutboundSheet(context, title: '研值相关服务')), + ], + ); + } +} + +class _ProfileRow extends StatelessWidget { + const _ProfileRow({required this.icon, required this.title, required this.subtitle, required this.onTap}); + + final IconData icon; + final String title; + final String subtitle; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: WiseSpacing.x3), + child: AppCard( + onTap: onTap, + child: Row( + children: [ + Icon(icon, color: WiseColors.primary), + const SizedBox(width: WiseSpacing.x3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleMedium), + Text(subtitle, style: Theme.of(context).textTheme.bodySmall), + ], + ), + ), + const Icon(Icons.chevron_right, color: WiseColors.textTertiary), + ], + ), + ), + ); + } +} diff --git a/lib/features/reports/reports_page.dart b/lib/features/reports/reports_page.dart new file mode 100644 index 0000000..c8e0b89 --- /dev/null +++ b/lib/features/reports/reports_page.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; + +import '../../data/api/report_data_source.dart'; +import '../../data/models/models.dart'; +import '../../routing/app_routes.dart'; +import '../../theme/wise_tokens.dart'; +import '../../widgets/app_buttons.dart'; +import '../../widgets/badges.dart'; +import '../../widgets/mini_player.dart'; +import '../../widgets/states.dart'; +import '../shared/report_card_widget.dart'; + +class ReportsPage extends StatefulWidget { + const ReportsPage({ + required this.dataSource, + required this.onPlay, + this.player = const PlayerStateModel(), + this.onStartModuleAudio, + this.onToggleAudio, + this.onSeekAudio, + this.onSpeed, + super.key, + }); + + final ReportDataSource dataSource; + final void Function(AudioItem item) onPlay; + final PlayerStateModel player; + final void Function(String audioId, String reportId, String title, int durationSec)? onStartModuleAudio; + final VoidCallback? onToggleAudio; + final void Function(int delta)? onSeekAudio; + final VoidCallback? onSpeed; + + @override + State createState() => _ReportsPageState(); +} + +class _ReportsPageState extends State { + late Future> future = widget.dataSource.reports(); + String query = ''; + String topic = ''; + bool hasAudio = false; + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: future, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) return const LoadingState(label: '正在搜索研报'); + if (snapshot.hasError) return ErrorState(message: snapshot.error.toString(), onRetry: () => setState(() => future = widget.dataSource.reports())); + final items = applyFilters(snapshot.data ?? const []); + return ListView( + padding: const EdgeInsets.all(WiseSpacing.x4), + children: [ + TextField( + decoration: InputDecoration( + hintText: '搜索标题、机构或主题', + prefixIcon: const Icon(Icons.search), + suffixIcon: query.isEmpty ? null : IconButton(onPressed: () => setState(() => query = ''), icon: const Icon(Icons.close)), + filled: true, + fillColor: WiseColors.surface, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(WiseRadius.pill), borderSide: BorderSide.none), + ), + onChanged: (value) => setState(() => query = value.trim()), + ), + const SizedBox(height: WiseSpacing.x3), + Row( + children: [ + AppButton(label: '筛选', icon: Icons.tune, kind: AppButtonKind.ghost, onPressed: openFilterSheet), + const SizedBox(width: WiseSpacing.x2), + AppChip(label: '有音频', selected: hasAudio, onTap: () => setState(() => hasAudio = !hasAudio)), + ], + ), + const SizedBox(height: WiseSpacing.x3), + Text('共 ${items.length} 篇研报解读${query.isNotEmpty || topic.isNotEmpty || hasAudio ? '(已筛选)' : ''}', style: Theme.of(context).textTheme.bodySmall), + const SizedBox(height: WiseSpacing.x3), + if (items.isEmpty) + EmptyState( + title: query.isNotEmpty ? '未找到相关研报' : '当前筛选下暂无研报', + message: query.isNotEmpty ? '换个关键词试试' : '调整筛选条件后再试', + actionLabel: '清除筛选', + onAction: () => setState(() { + query = ''; + topic = ''; + hasAudio = false; + }), + ) + else + for (final report in items) ...[ + ReportCardWidget( + report: report, + onTap: () => openReportDetail( + context, + widget.dataSource, + report, + player: widget.player, + onStartAudio: widget.onStartModuleAudio, + onToggleAudio: widget.onToggleAudio, + onSeekAudio: widget.onSeekAudio, + onSpeed: widget.onSpeed, + ), + onPlayTap: () => widget.onPlay(AudioItem(audioId: 'local_${report.id}', reportId: report.id, titleCn: report.titleCn, reportTitleCn: report.titleCn, durationSec: 180, institution: report.institution)), + ), + const SizedBox(height: WiseSpacing.x3), + ], + ], + ); + }, + ); + } + + List applyFilters(List items) { + return items.where((item) { + final hay = '${item.titleCn} ${item.institution.nameCn} ${item.topics.join(' ')}'.toLowerCase(); + if (query.isNotEmpty && !hay.contains(query.toLowerCase())) return false; + if (topic.isNotEmpty && !item.topics.contains(topic)) return false; + if (hasAudio && !item.hasAudio) return false; + return true; + }).toList(); + } + + void openFilterSheet() { + widget.dataSource.reports().then((items) { + if (!mounted) return; + final topics = {for (final item in items) ...item.topics}.toList(); + showModalBottomSheet( + context: context, + showDragHandle: true, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(WiseRadius.lg))), + builder: (context) => Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 28), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('筛选研报', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: WiseSpacing.x3), + Wrap( + spacing: WiseSpacing.x2, + runSpacing: WiseSpacing.x2, + children: [ + AppChip(label: '全部主题', selected: topic.isEmpty, onTap: () => selectTopic('')), + for (final t in topics) AppChip(label: t, selected: topic == t, onTap: () => selectTopic(t)), + ], + ), + const SizedBox(height: WiseSpacing.x4), + AppButton(label: '完成', expand: true, onPressed: () => Navigator.pop(context)), + ], + ), + ), + ); + }); + } + + void selectTopic(String value) { + setState(() => topic = value); + } +} diff --git a/lib/features/shared/report_card_widget.dart b/lib/features/shared/report_card_widget.dart new file mode 100644 index 0000000..e1f7f18 --- /dev/null +++ b/lib/features/shared/report_card_widget.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; + +import '../../data/models/models.dart'; +import '../../theme/wise_tokens.dart'; +import '../../widgets/app_card.dart'; +import '../../widgets/badges.dart'; + +class ReportCardWidget extends StatelessWidget { + const ReportCardWidget({ + required this.report, + required this.onTap, + this.hero = false, + this.onInstitutionTap, + this.onPlayTap, + super.key, + }); + + final ReportCardModel report; + final VoidCallback onTap; + final bool hero; + final VoidCallback? onInstitutionTap; + final VoidCallback? onPlayTap; + + @override + Widget build(BuildContext context) { + final child = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: WiseSpacing.x2, + runSpacing: WiseSpacing.x2, + children: [ + AppBadge(text: report.interpretationLabel, kind: BadgeKind.brand), + if (report.hasAudio) + const AppBadge(text: '音频', icon: Icons.graphic_eq, kind: BadgeKind.audio), + if (report.sourceTier.isNotEmpty) + AppBadge(text: report.sourceTier, icon: Icons.verified_outlined, kind: BadgeKind.tier), + for (final topic in report.topics.take(3)) AppBadge(text: topic), + ], + ), + const SizedBox(height: WiseSpacing.x3), + Text( + report.titleCn, + maxLines: hero ? 3 : 2, + overflow: TextOverflow.ellipsis, + style: hero + ? Theme.of(context).textTheme.titleLarge + : Theme.of(context).textTheme.titleMedium, + ), + if (report.oneLiner.isNotEmpty) ...[ + const SizedBox(height: WiseSpacing.x2), + Text( + report.oneLiner, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: WiseSpacing.x3), + Row( + children: [ + Expanded( + child: InkWell( + onTap: onInstitutionTap, + child: Text( + '${report.institution.nameCn}${report.releasedAt == null ? '' : ' · ${formatDate(report.releasedAt)}'}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ), + if (report.hasAudio) + TextButton.icon( + onPressed: onPlayTap, + icon: const Icon(Icons.play_circle_outline, size: 18), + label: const Text('听研报'), + ), + ], + ), + ], + ); + return hero + ? HeroReportCard(onTap: onTap, child: child) + : AppCard(onTap: onTap, child: child); + } +} diff --git a/lib/features/shell_page.dart b/lib/features/shell_page.dart new file mode 100644 index 0000000..30886b5 --- /dev/null +++ b/lib/features/shell_page.dart @@ -0,0 +1,152 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../data/api/report_data_source.dart'; +import '../data/models/models.dart'; +import '../theme/wise_tokens.dart'; +import '../widgets/mini_player.dart'; +import 'feed/feed_page.dart'; +import 'institutions/institutions_page.dart'; +import 'listen/listen_page.dart'; +import 'profile/profile_page.dart'; +import 'reports/reports_page.dart'; + +class ShellPage extends StatefulWidget { + const ShellPage({required this.dataSource, super.key}); + + final ReportDataSource dataSource; + + @override + State createState() => _ShellPageState(); +} + +class _ShellPageState extends State { + int index = 0; + PlayerStateModel player = const PlayerStateModel(); + Timer? timer; + + @override + void dispose() { + timer?.cancel(); + super.dispose(); + } + + void startAudio(AudioItem item) { + timer?.cancel(); + setState(() { + player = PlayerStateModel( + audioId: item.audioId, + reportId: item.reportId, + title: item.titleCn, + durationSec: item.durationSec, + playing: true, + speed: player.speed, + ); + }); + timer = Timer.periodic(const Duration(seconds: 1), (_) => tick()); + } + + void startModuleAudio(String audioId, String reportId, String title, int durationSec) { + startAudio( + AudioItem( + audioId: audioId, + reportId: reportId, + titleCn: title, + reportTitleCn: title, + durationSec: durationSec, + institution: const Institution(id: '', nameCn: ''), + ), + ); + } + + void tick() { + if (!player.playing) return; + final next = player.positionSec + player.speed.round().clamp(1, 2); + setState(() { + player = player.copyWith( + positionSec: next >= player.durationSec ? player.durationSec : next, + playing: next < player.durationSec, + ); + }); + } + + void toggleAudio() { + if (!player.hasAudio) return; + setState(() => player = player.copyWith(playing: !player.playing)); + } + + void seekAudio(int delta) { + if (!player.hasAudio) return; + setState(() { + player = player.copyWith( + positionSec: (player.positionSec + delta).clamp(0, player.durationSec), + ); + }); + } + + void cycleSpeed() { + const speeds = [1.0, 1.25, 1.5, 2.0]; + final current = speeds.indexOf(player.speed); + setState(() => player = player.copyWith(speed: speeds[(current + 1) % speeds.length])); + } + + @override + Widget build(BuildContext context) { + final pages = [ + FeedPage( + dataSource: widget.dataSource, + onPlay: startAudio, + player: player, + onStartModuleAudio: startModuleAudio, + onToggleAudio: toggleAudio, + onSeekAudio: seekAudio, + onSpeed: cycleSpeed, + ), + ReportsPage( + dataSource: widget.dataSource, + onPlay: startAudio, + player: player, + onStartModuleAudio: startModuleAudio, + onToggleAudio: toggleAudio, + onSeekAudio: seekAudio, + onSpeed: cycleSpeed, + ), + InstitutionsPage(dataSource: widget.dataSource), + ListenPage(dataSource: widget.dataSource, onPlay: startAudio), + ProfilePage(dataSource: widget.dataSource), + ]; + return Scaffold( + appBar: AppBar( + title: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('研听'), + Text( + '全球机构研报中文解读', + style: TextStyle(fontSize: 12, color: WiseColors.textSecondary, fontWeight: FontWeight.w500), + ), + ], + ), + ), + body: pages[index], + bottomNavigationBar: Column( + mainAxisSize: MainAxisSize.min, + children: [ + MiniPlayer(player: player, onToggle: toggleAudio), + NavigationBar( + selectedIndex: index, + onDestinationSelected: (value) => setState(() => index = value), + destinations: const [ + NavigationDestination(icon: Icon(Icons.auto_awesome_outlined), selectedIcon: Icon(Icons.auto_awesome), label: '推荐'), + NavigationDestination(icon: Icon(Icons.article_outlined), selectedIcon: Icon(Icons.article), label: '研报'), + NavigationDestination(icon: Icon(Icons.account_balance_outlined), selectedIcon: Icon(Icons.account_balance), label: '机构'), + NavigationDestination(icon: Icon(Icons.headphones_outlined), selectedIcon: Icon(Icons.headphones), label: '听单'), + NavigationDestination(icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: '我的'), + ], + ), + ], + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..e44cf7d --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +import 'app.dart'; +import 'data/api/report_data_source.dart'; + +export 'app.dart'; +export 'data/api/report_data_source.dart'; +export 'data/models/models.dart'; + +void main() { + runApp(MyApp(dataSource: RnbApiDataSource())); +} diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart new file mode 100644 index 0000000..b597ea1 --- /dev/null +++ b/lib/routing/app_routes.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +import '../data/api/report_data_source.dart'; +import '../data/models/models.dart'; +import '../features/detail/report_detail_page.dart'; +import '../features/institutions/institution_detail_page.dart'; +import '../widgets/mini_player.dart'; + +void openReportDetail( + BuildContext context, + ReportDataSource dataSource, + ReportCardModel report, { + PlayerStateModel player = const PlayerStateModel(), + void Function(String audioId, String reportId, String title, int durationSec)? onStartAudio, + VoidCallback? onToggleAudio, + void Function(int delta)? onSeekAudio, + VoidCallback? onSpeed, +}) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ReportDetailPage( + reportId: report.id, + dataSource: dataSource, + player: player, + onStartAudio: onStartAudio, + onToggleAudio: onToggleAudio, + onSeekAudio: onSeekAudio, + onSpeed: onSpeed, + ), + ), + ); +} + +void openInstitutionDetail( + BuildContext context, + ReportDataSource dataSource, + String institutionId, +) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => InstitutionDetailPage( + institutionId: institutionId, + dataSource: dataSource, + ), + ), + ); +} diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart new file mode 100644 index 0000000..e5b1025 --- /dev/null +++ b/lib/theme/app_theme.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; + +import 'wise_tokens.dart'; + +ThemeData buildAppTheme() { + final scheme = ColorScheme.fromSeed( + seedColor: WiseColors.primary, + primary: WiseColors.primary, + secondary: WiseColors.secondary, + tertiary: WiseColors.accent, + surface: WiseColors.surface, + ); + return ThemeData( + useMaterial3: true, + colorScheme: scheme, + fontFamily: 'Inter', + scaffoldBackgroundColor: WiseColors.canvas, + appBarTheme: const AppBarTheme( + backgroundColor: WiseColors.canvas, + foregroundColor: WiseColors.primary, + elevation: 0, + centerTitle: false, + titleTextStyle: TextStyle( + color: WiseColors.primary, + fontSize: 22, + fontWeight: FontWeight.w800, + ), + ), + cardTheme: const CardThemeData( + color: WiseColors.surface, + elevation: 0, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(WiseRadius.md)), + ), + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: WiseColors.surface, + indicatorColor: WiseColors.secondary200, + labelTextStyle: WidgetStateProperty.resolveWith( + (states) => TextStyle( + color: states.contains(WidgetState.selected) + ? WiseColors.primary + : WiseColors.textTertiary, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + iconTheme: WidgetStateProperty.resolveWith( + (states) => IconThemeData( + color: states.contains(WidgetState.selected) + ? WiseColors.primary + : WiseColors.textTertiary, + ), + ), + ), + textTheme: const TextTheme( + headlineSmall: TextStyle( + color: WiseColors.ink, + fontSize: 26, + height: 1.18, + fontWeight: FontWeight.w800, + ), + titleLarge: TextStyle( + color: WiseColors.ink, + fontSize: 21, + height: 1.22, + fontWeight: FontWeight.w800, + ), + titleMedium: TextStyle( + color: WiseColors.ink, + fontSize: 17, + height: 1.25, + fontWeight: FontWeight.w800, + ), + bodyLarge: TextStyle( + color: WiseColors.ink, + fontSize: 16, + height: 1.55, + ), + bodyMedium: TextStyle( + color: WiseColors.ink700, + fontSize: 14, + height: 1.5, + ), + bodySmall: TextStyle( + color: WiseColors.textSecondary, + fontSize: 12, + height: 1.45, + ), + labelSmall: TextStyle( + color: WiseColors.textSecondary, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ); +} diff --git a/lib/theme/wise_tokens.dart b/lib/theme/wise_tokens.dart new file mode 100644 index 0000000..bfe3402 --- /dev/null +++ b/lib/theme/wise_tokens.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; + +final class WiseColors { + static const primary = Color(0xFF163300); + static const primarySoft = Color(0xFF1F4708); + static const secondary = Color(0xFF9FE870); + static const secondary200 = Color(0xFFE2F6D5); + static const accent = Color(0xFF00A2DD); + static const canvas = Color(0xFFF4F6F3); + static const ink = Color(0xFF0E0F0C); + static const ink700 = Color(0xFF454745); + static const textSecondary = Color(0xFF5D7079); + static const textTertiary = Color(0xFF768E9C); + static const surface = Colors.white; + static const border = Color(0x1A000000); + static const positive = Color(0xFF008026); + static const warning = Color(0xFF9A6500); + static const negative = Color(0xFFCF2929); +} + +final class WiseSpacing { + static const x1 = 4.0; + static const x2 = 8.0; + static const x3 = 12.0; + static const x4 = 16.0; + static const x5 = 20.0; + static const x6 = 24.0; + static const x8 = 32.0; + static const x10 = 40.0; +} + +final class WiseRadius { + static const sm = 10.0; + static const md = 16.0; + static const lg = 24.0; + static const pill = 999.0; +} + +final class WiseMotion { + static const short = Duration(milliseconds: 200); + static const base = Duration(milliseconds: 350); + static const curve = Cubic(0.8, 0.05, 0.2, 0.95); +} + +final class WiseShadows { + static const card = [ + BoxShadow( + color: Color(0x14000000), + blurRadius: 20, + offset: Offset(0, 6), + ), + ]; + static const elevated = [ + BoxShadow( + color: Color(0x24000000), + blurRadius: 32, + offset: Offset(0, 10), + ), + ]; +} + +const wiseFontStack = [ + 'Inter', + '-apple-system', + 'BlinkMacSystemFont', + 'PingFang SC', + 'Microsoft YaHei', + 'Helvetica Neue', + 'Arial', + 'sans-serif', +]; diff --git a/lib/widgets/app_buttons.dart b/lib/widgets/app_buttons.dart new file mode 100644 index 0000000..dbc8972 --- /dev/null +++ b/lib/widgets/app_buttons.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +import '../theme/wise_tokens.dart'; + +class AppButton extends StatelessWidget { + const AppButton({ + required this.label, + required this.onPressed, + this.icon, + this.kind = AppButtonKind.primary, + this.expand = false, + super.key, + }); + + final String label; + final VoidCallback? onPressed; + final IconData? icon; + final AppButtonKind kind; + final bool expand; + + @override + Widget build(BuildContext context) { + final colors = switch (kind) { + AppButtonKind.primary => (WiseColors.secondary, WiseColors.primary), + AppButtonKind.dark => (WiseColors.primary, Colors.white), + AppButtonKind.accent => (WiseColors.accent, Colors.white), + AppButtonKind.ghost => (WiseColors.surface, WiseColors.primary), + }; + final child = FilledButton.icon( + onPressed: onPressed, + icon: icon == null ? const SizedBox.shrink() : Icon(icon, size: 18), + label: Text(label), + style: FilledButton.styleFrom( + backgroundColor: colors.$1, + foregroundColor: colors.$2, + disabledBackgroundColor: WiseColors.border, + disabledForegroundColor: WiseColors.textTertiary, + minimumSize: Size(expand ? double.infinity : 0, 44), + shape: const StadiumBorder(), + ), + ); + return expand ? SizedBox(width: double.infinity, child: child) : child; + } +} + +enum AppButtonKind { primary, dark, accent, ghost } diff --git a/lib/widgets/app_card.dart b/lib/widgets/app_card.dart new file mode 100644 index 0000000..62a105e --- /dev/null +++ b/lib/widgets/app_card.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +import '../theme/wise_tokens.dart'; + +class AppCard extends StatelessWidget { + const AppCard({ + required this.child, + this.onTap, + this.padding = const EdgeInsets.all(WiseSpacing.x4), + this.color = WiseColors.surface, + super.key, + }); + + final Widget child; + final VoidCallback? onTap; + final EdgeInsetsGeometry padding; + final Color color; + + @override + Widget build(BuildContext context) { + final content = DecoratedBox( + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(WiseRadius.md), + boxShadow: WiseShadows.card, + ), + child: Padding(padding: padding, child: child), + ); + if (onTap == null) return content; + return InkWell( + borderRadius: BorderRadius.circular(WiseRadius.md), + onTap: onTap, + child: content, + ); + } +} + +class HeroReportCard extends StatelessWidget { + const HeroReportCard({required this.child, this.onTap, super.key}); + + final Widget child; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return AppCard( + onTap: onTap, + color: WiseColors.secondary200, + padding: const EdgeInsets.all(WiseSpacing.x5), + child: child, + ); + } +} diff --git a/lib/widgets/badges.dart b/lib/widgets/badges.dart new file mode 100644 index 0000000..964818e --- /dev/null +++ b/lib/widgets/badges.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; + +import '../theme/wise_tokens.dart'; + +class AppBadge extends StatelessWidget { + const AppBadge({ + required this.text, + this.icon, + this.kind = BadgeKind.neutral, + super.key, + }); + + final String text; + final IconData? icon; + final BadgeKind kind; + + @override + Widget build(BuildContext context) { + final colors = switch (kind) { + BadgeKind.brand => (WiseColors.secondary200, WiseColors.primarySoft), + BadgeKind.audio => (const Color(0x1F00A2DD), WiseColors.accent), + BadgeKind.tier => (const Color(0x1A008026), WiseColors.positive), + BadgeKind.warning => (const Color(0x209A6500), WiseColors.warning), + BadgeKind.neutral => (const Color(0x1286A7BD), WiseColors.textSecondary), + }; + return DecoratedBox( + decoration: BoxDecoration( + color: colors.$1, + borderRadius: BorderRadius.circular(WiseRadius.pill), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 14, color: colors.$2), + const SizedBox(width: 4), + ], + Text( + text, + style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colors.$2), + ), + ], + ), + ), + ); + } +} + +enum BadgeKind { brand, audio, tier, warning, neutral } + +class AppChip extends StatelessWidget { + const AppChip({ + required this.label, + this.selected = false, + this.onTap, + super.key, + }); + + final String label; + final bool selected; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return ActionChip( + onPressed: onTap, + label: Text(label), + labelStyle: TextStyle( + color: selected ? Colors.white : WiseColors.textSecondary, + fontWeight: FontWeight.w700, + ), + backgroundColor: selected ? WiseColors.primary : WiseColors.surface, + side: const BorderSide(color: WiseColors.border), + shape: const StadiumBorder(), + ); + } +} diff --git a/lib/widgets/mini_player.dart b/lib/widgets/mini_player.dart new file mode 100644 index 0000000..1f4ae54 --- /dev/null +++ b/lib/widgets/mini_player.dart @@ -0,0 +1,188 @@ +import 'package:flutter/material.dart'; + +import '../data/models/models.dart'; +import '../theme/wise_tokens.dart'; +import 'app_card.dart'; + +class PlayerStateModel { + const PlayerStateModel({ + this.audioId = '', + this.reportId = '', + this.title = '', + this.durationSec = 0, + this.positionSec = 0, + this.playing = false, + this.speed = 1.0, + }); + + final String audioId; + final String reportId; + final String title; + final int durationSec; + final int positionSec; + final bool playing; + final double speed; + + bool get hasAudio => audioId.isNotEmpty; + + PlayerStateModel copyWith({ + String? audioId, + String? reportId, + String? title, + int? durationSec, + int? positionSec, + bool? playing, + double? speed, + }) { + return PlayerStateModel( + audioId: audioId ?? this.audioId, + reportId: reportId ?? this.reportId, + title: title ?? this.title, + durationSec: durationSec ?? this.durationSec, + positionSec: positionSec ?? this.positionSec, + playing: playing ?? this.playing, + speed: speed ?? this.speed, + ); + } +} + +class MiniPlayer extends StatelessWidget { + const MiniPlayer({ + required this.player, + required this.onToggle, + super.key, + }); + + final PlayerStateModel player; + final VoidCallback onToggle; + + @override + Widget build(BuildContext context) { + if (!player.hasAudio) return const SizedBox.shrink(); + final ratio = player.durationSec == 0 ? 0.0 : player.positionSec / player.durationSec; + return Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: AppCard( + padding: const EdgeInsets.all(12), + color: WiseColors.primary, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + IconButton.filled( + onPressed: onToggle, + icon: Icon(player.playing ? Icons.pause : Icons.play_arrow), + style: IconButton.styleFrom( + backgroundColor: WiseColors.secondary, + foregroundColor: WiseColors.primary, + ), + ), + const SizedBox(width: WiseSpacing.x2), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + player.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w800), + ), + Text( + '${formatDuration(player.positionSec)} / ${formatDuration(player.durationSec)} · ${player.speed.toStringAsFixed(1)}x', + style: const TextStyle(color: Color(0xCCFFFFFF), fontSize: 12), + ), + ], + ), + ), + ], + ), + const SizedBox(height: WiseSpacing.x2), + LinearProgressIndicator( + value: ratio.clamp(0, 1), + minHeight: 4, + backgroundColor: const Color(0x33FFFFFF), + color: WiseColors.secondary, + ), + ], + ), + ), + ); + } +} + +class PlayerCard extends StatelessWidget { + const PlayerCard({ + required this.title, + required this.durationSec, + required this.player, + required this.onStart, + required this.onToggle, + required this.onSeek, + required this.onSpeed, + super.key, + }); + + final String title; + final int durationSec; + final PlayerStateModel player; + final VoidCallback onStart; + final VoidCallback onToggle; + final void Function(int delta) onSeek; + final VoidCallback onSpeed; + + @override + Widget build(BuildContext context) { + final active = player.hasAudio && player.title == title; + final position = active ? player.positionSec : 0; + final ratio = durationSec == 0 ? 0.0 : position / durationSec; + return AppCard( + color: WiseColors.secondary200, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('音频解读', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: WiseSpacing.x2), + Text(title, style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: WiseSpacing.x3), + LinearProgressIndicator( + value: ratio.clamp(0, 1), + minHeight: 6, + backgroundColor: Colors.white, + color: WiseColors.accent, + ), + const SizedBox(height: WiseSpacing.x2), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(formatDuration(position), style: Theme.of(context).textTheme.bodySmall), + Text(formatDuration(durationSec), style: Theme.of(context).textTheme.bodySmall), + ], + ), + const SizedBox(height: WiseSpacing.x3), + Row( + children: [ + IconButton.outlined(onPressed: () => onSeek(-15), icon: const Icon(Icons.replay_10)), + IconButton.filled( + onPressed: active ? onToggle : onStart, + icon: Icon(active && player.playing ? Icons.pause : Icons.play_arrow), + style: IconButton.styleFrom( + backgroundColor: WiseColors.primary, + foregroundColor: Colors.white, + ), + ), + IconButton.outlined(onPressed: () => onSeek(15), icon: const Icon(Icons.forward_10)), + const Spacer(), + TextButton(onPressed: onSpeed, child: Text('${player.speed.toStringAsFixed(1)}x')), + ], + ), + Text( + '真实音频流待 /audio/{id}/stream 接入,当前为本地进度占位。', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ); + } +} diff --git a/lib/widgets/sheets.dart b/lib/widgets/sheets.dart new file mode 100644 index 0000000..f698c18 --- /dev/null +++ b/lib/widgets/sheets.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; + +import '../theme/wise_tokens.dart'; +import 'app_buttons.dart'; +import 'states.dart'; + +Future showLoginSheet(BuildContext context, {String reason = '登录后保存当前动作'}) { + return showModalBottomSheet( + context: context, + showDragHandle: true, + backgroundColor: WiseColors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(WiseRadius.lg)), + ), + builder: (context) => Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 28), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('登录研听', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: WiseSpacing.x2), + Text(reason, style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: WiseSpacing.x4), + AppButton( + label: '使用手机号继续', + icon: Icons.phone_iphone, + expand: true, + onPressed: () { + Navigator.pop(context); + showAppToast(context, '登录接口待接入,已保留当前页面'); + }, + ), + const SizedBox(height: WiseSpacing.x2), + AppButton( + label: '微信 / Apple 登录占位', + icon: Icons.account_circle_outlined, + kind: AppButtonKind.ghost, + expand: true, + onPressed: () { + Navigator.pop(context); + showAppToast(context, '真实 auth 待后端接入'); + }, + ), + ], + ), + ), + ); +} + +Future showOutboundSheet(BuildContext context, {required String title}) { + return showModalBottomSheet( + context: context, + showDragHandle: true, + backgroundColor: WiseColors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(WiseRadius.lg)), + ), + builder: (context) => Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 28), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('即将打开外部服务', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: WiseSpacing.x2), + Text( + '$title\n外跳仅用于了解原文或相关服务,本内容不构成投资建议。', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: WiseSpacing.x4), + AppButton( + label: '确认并记录占位事件', + icon: Icons.open_in_new, + kind: AppButtonKind.accent, + expand: true, + onPressed: () { + Navigator.pop(context); + showAppToast(context, '外跳事件接口待接入'); + }, + ), + ], + ), + ), + ); +} diff --git a/lib/widgets/states.dart b/lib/widgets/states.dart new file mode 100644 index 0000000..bb5efdc --- /dev/null +++ b/lib/widgets/states.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; + +import '../theme/wise_tokens.dart'; +import 'app_buttons.dart'; +import 'app_card.dart'; + +class LoadingState extends StatelessWidget { + const LoadingState({this.label = '正在加载研报解读', super.key}); + + final String label; + + @override + Widget build(BuildContext context) { + return ListView.separated( + padding: const EdgeInsets.all(WiseSpacing.x4), + itemCount: 4, + separatorBuilder: (_, _) => const SizedBox(height: WiseSpacing.x3), + itemBuilder: (context, index) => const SkeletonCard(), + ); + } +} + +class SkeletonCard extends StatelessWidget { + const SkeletonCard({super.key}); + + @override + Widget build(BuildContext context) { + return AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + SkeletonLine(width: 96), + SizedBox(height: WiseSpacing.x3), + SkeletonLine(width: double.infinity, height: 18), + SizedBox(height: WiseSpacing.x2), + SkeletonLine(width: 240), + SizedBox(height: WiseSpacing.x3), + SkeletonLine(width: 160), + ], + ), + ); + } +} + +class SkeletonLine extends StatelessWidget { + const SkeletonLine({required this.width, this.height = 12, super.key}); + + final double width; + final double height; + + @override + Widget build(BuildContext context) { + return Container( + width: width, + height: height, + decoration: BoxDecoration( + color: WiseColors.border, + borderRadius: BorderRadius.circular(WiseRadius.pill), + ), + ); + } +} + +class EmptyState extends StatelessWidget { + const EmptyState({ + required this.title, + required this.message, + this.icon = Icons.search_off, + this.actionLabel, + this.onAction, + super.key, + }); + + final String title; + final String message; + final IconData icon; + final String? actionLabel; + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(WiseSpacing.x6), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 42, color: WiseColors.primary), + const SizedBox(height: WiseSpacing.x3), + Text(title, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: WiseSpacing.x2), + Text( + message, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + if (actionLabel != null) ...[ + const SizedBox(height: WiseSpacing.x4), + AppButton(label: actionLabel!, onPressed: onAction, kind: AppButtonKind.ghost), + ], + ], + ), + ), + ); + } +} + +class ErrorState extends StatelessWidget { + const ErrorState({required this.message, this.onRetry, super.key}); + + final String message; + final VoidCallback? onRetry; + + @override + Widget build(BuildContext context) { + return EmptyState( + icon: Icons.cloud_off_outlined, + title: '内容暂时加载失败', + message: message, + actionLabel: onRetry == null ? null : '重试', + onAction: onRetry, + ); + } +} + +void showAppToast(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + behavior: SnackBarBehavior.floating, + backgroundColor: WiseColors.primary, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(WiseRadius.md)), + ), + ); +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..b4b3ba2 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,245 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" +sdks: + dart: ">=3.12.1 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..b97add1 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,90 @@ +name: report_notebooklm_app +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.12.1 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + http: ^1.6.0 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..318bcc2 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,161 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:report_notebooklm_app/main.dart'; + +void main() { + testWidgets('renders shell tabs and report detail modules', (tester) async { + await tester.pumpWidget(MyApp(dataSource: FakeDataSource())); + await tester.pumpAndSettle(); + + expect(find.text('推荐'), findsWidgets); + expect(find.text('研报'), findsWidgets); + expect(find.text('机构'), findsWidgets); + expect(find.text('听单'), findsWidgets); + expect(find.text('我的'), findsWidgets); + expect(find.text('黄金月报:金价新高之后,谁在继续买?'), findsOneWidget); + + await tester.tap(find.text('黄金月报:金价新高之后,谁在继续买?')); + await tester.pumpAndSettle(); + + expect(find.text('研报详情'), findsOneWidget); + expect(find.text('报告要点'), findsWidgets); + expect(find.text('报告中的关键数据'), findsWidgets); + expect(find.text('查看详情'), findsWidgets); + + await tester.tap(find.text('报告摘要').last); + await tester.pumpAndSettle(); + + expect(find.text('报告摘要'), findsOneWidget); + expect(find.text('需求结构'), findsOneWidget); + }); +} + +class FakeDataSource implements ReportDataSource { + final institution = const Institution( + id: 'inst_ssga', + nameCn: '道富环球投资管理', + nameEn: 'State Street Global Advisors', + sourceTier: 'tier_2', + coveredTopics: ['贵金属'], + reportCount: 1, + ); + + late final report = ReportCardModel( + id: 'rep_ssga_gold', + titleCn: '黄金月报:金价新高之后,谁在继续买?', + oneLiner: '央行购金转向结构性,ETF 重新净流入。', + institution: institution, + topics: const ['贵金属', '跨资产'], + releasedAt: '2026-05-22T00:00:00', + hasAudio: true, + interpretationLabel: '研报解读', + sourceTier: 'authorized_partner', + cacheVersion: 'rep_ssga_gold:v1', + ); + + @override + Future> recommended() async => [report]; + + @override + Future> reports() async => [report]; + + @override + Future> institutions() async => [institution]; + + @override + Future institutionDetail(String institutionId) async { + return Institution( + id: institution.id, + nameCn: institution.nameCn, + nameEn: institution.nameEn, + sourceTier: institution.sourceTier, + coveredTopics: institution.coveredTopics, + reportCount: 1, + introCn: '道富环球投资管理的公开研究用于演示。', + credibilityNote: 'tier_2 来源。', + recentReports: [report], + ); + } + + @override + Future> listen() async => [ + AudioItem( + audioId: 'aud_ssga_gold', + titleCn: '金价新高之后,谁在继续买?', + reportTitleCn: report.titleCn, + durationSec: 180, + reportId: report.id, + institution: institution, + ), + ]; + + @override + Future reportDetail(String reportId) async => ReportDetail( + id: report.id, + titleCn: report.titleCn, + oneLiner: report.oneLiner, + institution: institution, + source: const { + 'source_tier': 'authorized_partner', + 'source_note': '原文来源于机构公开研究页。', + }, + topics: report.topics, + hasAudio: true, + releasedAt: report.releasedAt, + cacheVersion: report.cacheVersion, + modules: const [ + DisplayModule( + id: 'mod_ssga_gold_executive_overview', + type: 'executive_overview', + layer: 'p0', + renderMode: 'card_plus_page', + hasDetailPage: true, + sortOrder: 1, + titleCn: '报告摘要', + preview: {'preview_summary': '本期月报拆解黄金买盘。', 'section_count': 3}, + ), + DisplayModule( + id: 'mod_ssga_gold_core_insights', + type: 'core_insights', + layer: 'p0', + renderMode: 'inline', + hasDetailPage: true, + sortOrder: 2, + titleCn: '报告要点', + content: { + 'points': [ + {'kind': 'view', 'text': '央行购金从机会性买入转向结构性配置。'}, + ], + }, + ), + DisplayModule( + id: 'mod_ssga_gold_key_data', + type: 'key_data', + layer: 'p0', + renderMode: 'card_plus_page', + hasDetailPage: true, + sortOrder: 3, + titleCn: '报告中的关键数据', + preview: { + 'preview_headline': '10 个关键数据点', + 'highlights': ['ETF 连续净流入'], + }, + ), + ], + ); + + @override + Future moduleDetail(String reportId, String moduleId) async { + return ModuleDetail( + id: moduleId, + type: 'executive_overview', + titleCn: '报告摘要', + content: const { + 'sections': [ + {'heading': '需求结构', 'body': '央行与 ETF 买盘提供支撑。'}, + ], + }, + contentEtag: 'etag', + cacheVersion: 'rep_ssga_gold:v1', + ); + } +} diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..8aaa46ac1ae21512746f852a42ba87e4165dfdd1 GIT binary patch literal 917 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|I14-?iy0X7 zltGxWVyS%@P(fs7NJL45ua8x7ey(0(N`6wRUPW#JP&EUCO@$SZnVVXYs8ErclUHn2 zVXFjIVFhG^g!Ppaz)DK8ZIvQ?0~DO|i&7O#^-S~(l1AfjnEK zjFOT9D}DX)@^Za$W4-*MbbUihOG|wNBYh(yU7!lx;>x^|#0uTKVr7USFmqf|i<65o z3raHc^AtelCMM;Vme?vOfh>Xph&xL%(-1c06+^uR^q@XSM&D4+Kp$>4P^%3{)XKjo zGZknv$b36P8?Z_gF{nK@`XI}Z90TzwSQO}0J1!f2c(B=V`5aP@1P1a|PZ!4!3&Gl8 zTYqUsf!gYFyJnXpu0!n&N*SYAX-%d(5gVjrHJWqXQshj@!Zm{!01WsQrH~9=kTxW#6SvuapgMqt>$=j#%eyGrQzr zP{L-3gsMA^$I1&gsBAEL+vxi1*Igl=8#8`5?A-T5=z-sk46WA1IUT)AIZHx1rdUrf zVJrJn<74DDw`j)Ki#gt}mIT-Q`XRa2-jQXQoI%w`nb|XblvzK${ZzlV)m-XcwC(od z71_OEC5Bt9GEXosOXaPTYOia#R4ID2TiU~`zVMl08TV_C%DnU4^+HE>9(CE4D6?Fz oujB08i7adh9xk7*FX66dWH6F5TM;?E2b5PlUHx3vIVCg!0Dx9vYXATM literal 0 HcmV?d00001 diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..b749bfef07473333cf1dd31e9eed89862a5d52aa GIT binary patch literal 5292 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 literal 0 HcmV?d00001 diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..88cfd48dff1169879ba46840804b412fe02fefd6 GIT binary patch literal 8252 zcmd5=2T+s!lYZ%-(h(2@5fr2dC?F^$C=i-}R6$UX8af(!je;W5yC_|HmujSgN*6?W z3knF*TL1$|?oD*=zPbBVex*RUIKsL<(&Rj9%^UD2IK3W?2j>D?eWQgvS-HLymHo9%~|N2Q{~j za?*X-{b9JRowv_*Mh|;*-kPFn>PI;r<#kFaxFqbn?aq|PduQg=2Q;~Qc}#z)_T%x9 zE|0!a70`58wjREmAH38H1)#gof)U3g9FZ^ zF7&-0^Hy{4XHWLoC*hOG(dg~2g6&?-wqcpf{ z&3=o8vw7lMi22jCG9RQbv8H}`+}9^zSk`nlR8?Z&G2dlDy$4#+WOlg;VHqzuE=fM@ z?OI6HEJH4&tA?FVG}9>jAnq_^tlw8NbjNhfqk2rQr?h(F&WiKy03Sn=-;ZJRh~JrD zbt)zLbnabttEZ>zUiu`N*u4sfQaLE8-WDn@tHp50uD(^r-}UsUUu)`!Rl1PozAc!a z?uj|2QDQ%oV-jxUJmJycySBINSKdX{kDYRS=+`HgR2GO19fg&lZKyBFbbXhQV~v~L za^U944F1_GtuFXtvDdDNDvp<`fqy);>Vw=ncy!NB85Tw{&sT5&Ox%-p%8fTS;OzlRBwErvO+ROe?{%q-Zge=%Up|D4L#>4K@Ke=x%?*^_^P*KD zgXueMiS63!sEw@fNLB-i^F|@Oib+S4bcy{eu&e}Xvb^(mA!=U=Xr3||IpV~3K zQWzEsUeX_qBe6fky#M zzOJm5b+l;~>=sdp%i}}0h zO?B?i*W;Ndn02Y0GUUPxERG`3Bjtj!NroLoYtyVdLtl?SE*CYpf4|_${ku2s`*_)k zN=a}V8_2R5QANlxsq!1BkT6$4>9=-Ix4As@FSS;1q^#TXPrBsw>hJ}$jZ{kUHoP+H zvoYiR39gX}2OHIBYCa~6ERRPJ#V}RIIZakUmuIoLF*{sO8rAUEB9|+A#C|@kw5>u0 zBd=F!4I)Be8ycH*)X1-VPiZ+Ts8_GB;YW&ZFFUo|Sw|x~ZajLsp+_3gv((Q#N>?Jz zFBf`~p_#^${zhPIIJY~yo!7$-xi2LK%3&RkFg}Ax)3+dFCjGgKv^1;lUzQlPo^E{K zmCnrwJ)NuSaJEmueEPO@(_6h3f5mFffhkU9r8A8(JC5eOkux{gPmx_$Uv&|hyj)gN zd>JP8l2U&81@1Hc>#*su2xd{)T`Yw< zN$dSLUN}dfx)Fu`NcY}TuZ)SdviT{JHaiYgP4~@`x{&h*Hd>c3K_To9BnQi@;tuoL z%PYQo&{|IsM)_>BrF1oB~+`2_uZQ48z9!)mtUR zdfKE+b*w8cPu;F6RYJiYyV;PRBbThqHBEu_(U{(gGtjM}Zi$pL8Whx}<JwE3RM0F8x7%!!s)UJVq|TVd#hf1zVLya$;mYp(^oZQ2>=ZXU1c$}f zm|7kfk>=4KoQoQ!2&SOW5|JP1)%#55C$M(u4%SP~tHa&M+=;YsW=v(Old9L3(j)`u z2?#fK&1vtS?G6aOt@E`gZ9*qCmyvc>Ma@Q8^I4y~f3gs7*d=ATlP>1S zyF=k&6p2;7dn^8?+!wZO5r~B+;@KXFEn^&C=6ma1J7Au6y29iMIxd7#iW%=iUzq&C=$aPLa^Q zncia$@TIy6UT@69=nbty5epP>*fVW@5qbUcb2~Gg75dNd{COFLdiz3}kODn^U*=@E z0*$7u7Rl2u)=%fk4m8EK1ctR!6%Ve`e!O20L$0LkM#f+)n9h^dn{n`T*^~d+l*Qlx z$;JC0P9+en2Wlxjwq#z^a6pdnD6fJM!GV7_%8%c)kc5LZs_G^qvw)&J#6WSp< zmsd~1-(GrgjC56Pdf6#!dt^y8Rg}!#UXf)W%~PeU+kU`FeSZHk)%sFv++#Dujk-~m zFHvVJC}UBn2jN& zs!@nZ?e(iyZPNo`p1i#~wsv9l@#Z|ag3JR>0#u1iW9M1RK1iF6-RbJ4KYg?B`dET9 zyR~DjZ>%_vWYm*Z9_+^~hJ_|SNTzBKx=U0l9 z9x(J96b{`R)UVQ$I`wTJ@$_}`)_DyUNOso6=WOmQKI1e`oyYy1C&%AQU<0-`(ow)1 zT}gYdwWdm4wW6|K)LcfMe&psE0XGhMy&xS`@vLi|1#Za{D6l@#D!?nW87wcscUZgELT{Cz**^;Zb~7 z(~WFRO`~!WvyZAW-8v!6n&j*PLm9NlN}BuUN}@E^TX*4Or#dMMF?V9KBeLSiLO4?B zcE3WNIa-H{ThrlCoN=XjOGk1dT=xwwrmt<1a)mrRzg{35`@C!T?&_;Q4Ce=5=>z^*zE_c(0*vWo2_#TD<2)pLXV$FlwP}Ik74IdDQU@yhkCr5h zn5aa>B7PWy5NQ!vf7@p_qtC*{dZ8zLS;JetPkHi>IvPjtJ#ThGQD|Lq#@vE2xdl%`x4A8xOln}BiQ92Po zW;0%A?I5CQ_O`@Ad=`2BLPPbBuPUp@Hb%a_OOI}y{Rwa<#h z5^6M}s7VzE)2&I*33pA>e71d78QpF>sNK;?lj^Kl#wU7G++`N_oL4QPd-iPqBhhs| z(uVM}$ItF-onXuuXO}o$t)emBO3Hjfyil@*+GF;9j?`&67GBM;TGkLHi>@)rkS4Nj zAEk;u)`jc4C$qN6WV2dVd#q}2X6nKt&X*}I@jP%Srs%%DS92lpDY^K*Sx4`l;aql$ zt*-V{U&$DM>pdO?%jt$t=vg5|p+Rw?SPaLW zB6nvZ69$ne4Z(s$3=Rf&RX8L9PWMV*S0@R zuIk&ba#s6sxVZ51^4Kon46X^9`?DC9mEhWB3f+o4#2EXFqy0(UTc>GU| zGCJmI|Dn-dX#7|_6(fT)>&YQ0H&&JX3cTvAq(a@ydM4>5Njnuere{J8p;3?1az60* z$1E7Yyxt^ytULeokgDnRVKQw9vzHg1>X@@jM$n$HBlveIrKP5-GJq%iWH#odVwV6cF^kKX(@#%%uQVb>#T6L^mC@)%SMd4DF? zVky!~ge27>cpUP1Vi}Z32lbLV+CQy+T5Wdmva6Fg^lKb!zrg|HPU=5Qu}k;4GVH+x z%;&pN1LOce0w@9i1Mo-Y|7|z}fbch@BPp2{&R-5{GLoeu8@limQmFF zaJRR|^;kW_nw~0V^ zfTnR!Ni*;-%oSHG1yItARs~uxra|O?YJxBzLjpeE-=~TO3Dn`JL5Gz;F~O1u3|FE- zvK2Vve`ylc`a}G`gpHg58Cqc9fMoy1L}7x7T>%~b&irrNMo?np3`q;d3d;zTK>nrK zOjPS{@&74-fA7j)8uT9~*g23uGnxwIVj9HorzUX#s0pcp2?GH6i}~+kv9fWChtPa_ z@T3m+$0pbjdQw7jcnHn;Pi85hk_u2-1^}c)LNvjdam8K-XJ+KgKQ%!?2n_!#{$H|| zLO=%;hRo6EDmnOBKCL9Cg~ETU##@u^W_5joZ%Et%X_n##%JDOcsO=0VL|Lkk!VdRJ z^|~2pB@PUspT?NOeO?=0Vb+fAGc!j%Ufn-cB`s2A~W{Zj{`wqWq_-w0wr@6VrM zbzni@8c>WS!7c&|ZR$cQ;`niRw{4kG#e z70e!uX8VmP23SuJ*)#(&R=;SxGAvq|&>geL&!5Z7@0Z(No*W561n#u$Uc`f9pD70# z=sKOSK|bF~#khTTn)B28h^a1{;>EaRnHj~>i=Fnr3+Fa4 z`^+O5_itS#7kPd20rq66_wH`%?HNzWk@XFK0n;Z@Cx{kx==2L22zWH$Yg?7 zvDj|u{{+NR3JvUH({;b*$b(U5U z7(lF!1bz2%06+|-v(D?2KgwNw7( zJB#Tz+ZRi&U$i?f34m7>uTzO#+E5cbaiQ&L}UxyOQq~afbNB4EI{E04ZWg53w0A{O%qo=lF8d zf~ktGvIgf-a~zQoWf>loF7pOodrd0a2|BzwwPDV}ShauTK8*fmF6NRbO>Iw9zZU}u zw8Ya}?seBnEGQDmH#XpUUkj}N49tP<2jYwTFp!P+&Fd(%Z#yo80|5@zN(D{_pNow*&4%ql zW~&yp@scb-+Qj-EmErY+Tu=dUmf@*BoXY2&oKT8U?8?s1d}4a`Aq>7SV800m$FE~? zjmz(LY+Xx9sDX$;vU`xgw*jLw7dWOnWWCO8o|;}f>cu0Q&`0I{YudMn;P;L3R-uz# zfns_mZED_IakFBPP2r_S8XM$X)@O-xVKi4`7373Jkd5{2$M#%cRhWer3M(vr{S6>h zj{givZJ3(`yFL@``(afn&~iNx@B1|-qfYiZu?-_&Z8+R~v`d6R-}EX9IVXWO-!hL5 z*k6T#^2zAXdardU3Ao~I)4DGdAv2bx{4nOK`20rJo>rmk3S2ZDu}))8Z1m}CKigf0 z3L`3Y`{huj`xj9@`$xTZzZc3je?n^yG<8sw$`Y%}9mUsjUR%T!?k^(q)6FH6Af^b6 zlPg~IEwg0y;`t9y;#D+uz!oE4VP&Je!<#q*F?m5L5?J3i@!0J6q#eu z!RRU`-)HeqGi_UJZ(n~|PSNsv+Wgl{P-TvaUQ9j?ZCtvb^37U$sFpBrkT{7Jpd?HpIvj2!}RIq zH{9~+gErN2+}J`>Jvng2hwM`=PLNkc7pkjblKW|+Fk9rc)G1R>Ww>RC=r-|!m-u7( zc(a$9NG}w#PjWNMS~)o=i~WA&4L(YIW25@AL9+H9!?3Y}sv#MOdY{bb9j>p`{?O(P zIvb`n?_(gP2w3P#&91JX*md+bBEr%xUHMVqfB;(f?OPtMnAZ#rm5q5mh;a2f_si2_ z3oXWB?{NF(JtkAn6F(O{z@b76OIqMC$&oJ_&S|YbFJ*)3qVX_uNf5b8(!vGX19hsG z(OP>RmZp29KH9Ge2kKjKigUmOe^K_!UXP`von)PR8Qz$%=EmOB9xS(ZxE_tnyzo}7 z=6~$~9k0M~v}`w={AeqF?_)9q{m8K#6M{a&(;u;O41j)I$^T?lx5(zlebpY@NT&#N zR+1bB)-1-xj}R8uwqwf=iP1GbxBjneCC%UrSdSxK1vM^i9;bUkS#iRZw2H>rS<2<$ zNT3|sDH>{tXb=zq7XZi*K?#Zsa1h1{h5!Tq_YbKFm_*=A5-<~j63he;4`77!|LBlo zR^~tR3yxcU=gDFbshyF6>o0bdp$qmHS7D}m3;^QZq9kBBU|9$N-~oU?G5;jyFR7>z hN`IR97YZXIo@y!QgFWddJ3|0`sjFx!m))><{BI=FK%f8s literal 0 HcmV?d00001 diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000000000000000000000000000000000000..eb9b4d76e525556d5d89141648c724331630325d GIT binary patch literal 5594 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 X>c!)3PgKfL&lX^$Z>Cpu&6)6jvi^Z! literal 0 HcmV?d00001 diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..d69c56691fbdb0b7efa65097c7cc1edac12a6d3e GIT binary patch literal 20998 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 MPgg&ebxsLQ025IeI{*Lx literal 0 HcmV?d00001 diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..33ab85e --- /dev/null +++ b/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + 研听 + + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..b8728f1 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "研听", + "short_name": "研听", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "研听 - 全球机构研报中文解读 App", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +}