chore: prepare yanting monorepo handoff

This commit is contained in:
2026-06-03 10:39:03 +09:00
commit 634ae98dec
65 changed files with 5144 additions and 0 deletions
+47
View File
@@ -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
+30
View File
@@ -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'
+28
View File
@@ -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.
+77
View File
@@ -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=<api-base-url>
```
Android emulator:
```bash
flutter run -d <emulator-id> --dart-define=RNB_API_BASE=<emulator-api-base-url>
```
Same-network Android device:
```bash
flutter run -d <device-id> --dart-define=RNB_API_BASE=http://<host-lan-ip>:<port>/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=<api-base-url>
flutter build apk --debug --dart-define=RNB_API_BASE=<emulator-api-base-url>
```
## 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.
+28
View File
@@ -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
+11
View File
@@ -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
+45
View File
@@ -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 = "../.."
}
@@ -0,0 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
<application android:usesCleartextTraffic="true"/>
</manifest>
+47
View File
@@ -0,0 +1,47 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="研听"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package com.example.report_notebooklm_app
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+24
View File
@@ -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<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+6
View File
@@ -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
Binary file not shown.
+5
View File
@@ -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
Vendored Executable
+160
View File
@@ -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 "$@"
+90
View File
@@ -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
+26
View File
@@ -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")
+158
View File
@@ -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.
+81
View File
@@ -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=<api-base-url>
```
Examples:
```bash
RNB_API_BASE=http://<debug-api-host>:<port>/api/report-notebooklm/v1
RNB_API_BASE=https://<api-domain>/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=<api-base-url>
```
## Android Emulator Run
Start the backend on the host, then:
```bash
flutter devices
flutter run -d <emulator-id> --dart-define=RNB_API_BASE=<emulator-api-base-url>
```
## Same-Network Android Device
Start the backend on a host/port reachable from the device, then:
```bash
flutter run -d <device-id> --dart-define=RNB_API_BASE=http://<host-lan-ip>:<port>/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=<api-base-url>
flutter build apk --debug --dart-define=RNB_API_BASE=<emulator-api-base-url>
```
## 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.
+79
View File
@@ -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.
+65
View File
@@ -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.
+75
View File
@@ -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
+135
View File
@@ -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<ScrollNotification>(
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(() {});
}
}
}
+77
View File
@@ -0,0 +1,77 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/models.dart';
abstract class ReportDataSource {
Future<List<ReportCardModel>> recommended();
Future<List<ReportCardModel>> reports();
Future<List<Institution>> institutions();
Future<Institution> institutionDetail(String institutionId);
Future<List<AudioItem>> listen();
Future<ReportDetail> reportDetail(String reportId);
Future<ModuleDetail> 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<JsonMap> _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<JsonMap> _items(JsonMap body) => asMapList(body['items']);
@override
Future<List<ReportCardModel>> recommended() async {
final body = await _get('/feed/recommended');
return _items(body).map(ReportCardModel.fromJson).toList();
}
@override
Future<List<ReportCardModel>> reports() async {
final body = await _get('/reports');
return _items(body).map(ReportCardModel.fromJson).toList();
}
@override
Future<List<Institution>> institutions() async {
final body = await _get('/institutions');
return _items(body).map(Institution.fromJson).toList();
}
@override
Future<Institution> institutionDetail(String institutionId) async {
return Institution.fromJson(await _get('/institutions/$institutionId'));
}
@override
Future<List<AudioItem>> listen() async {
final body = await _get('/listen');
return _items(body).map(AudioItem.fromJson).toList();
}
@override
Future<ReportDetail> reportDetail(String reportId) async {
return ReportDetail.fromJson(await _get('/reports/$reportId'));
}
@override
Future<ModuleDetail> moduleDetail(String reportId, String moduleId) async {
return ModuleDetail.fromJson(await _get('/reports/$reportId/modules/$moduleId'));
}
}
+313
View File
@@ -0,0 +1,313 @@
typedef JsonMap = Map<String, dynamic>;
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<String> asStringList(Object? value) {
if (value is List) return value.map((item) => item.toString()).toList();
return const [];
}
JsonMap asMap(Object? value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return value.map((key, val) => MapEntry(key.toString(), val));
return const {};
}
List<JsonMap> 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<String> coveredTopics;
final int reportCount;
final String? latestReportAt;
final String credibilityNote;
final String introCn;
final List<ReportCardModel> 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<String> 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<String> topics;
final bool hasAudio;
final String interpretationLabel;
final String riskDisclaimer;
final String? releasedAt;
final String cacheVersion;
final List<DisplayModule> 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']),
);
}
}
@@ -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<ModuleDetailPage> createState() => _ModuleDetailPageState();
}
class _ModuleDetailPageState extends State<ModuleDetailPage> {
late Future<ModuleDetail> 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<ModuleDetail>(
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,
};
+199
View File
@@ -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<ReportDetailPage> createState() => _ReportDetailPageState();
}
class _ReportDetailPageState extends State<ReportDetailPage> {
static const registry = ModuleRendererRegistry();
late Future<ReportDetail> future = widget.dataSource.reportDetail(
widget.reportId,
);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('研报详情')),
body: FutureBuilder<ReportDetail>(
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<DisplayModule> 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),
),
],
),
);
}
}
+124
View File
@@ -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<FeedPage> createState() => _FeedPageState();
}
class _FeedPageState extends State<FeedPage> {
String topic = '全部';
late Future<List<ReportCardModel>> future = widget.dataSource.recommended();
@override
Widget build(BuildContext context) {
return FutureBuilder<List<ReportCardModel>>(
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,
),
);
}
}
@@ -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<InstitutionDetailPage> createState() => _InstitutionDetailPageState();
}
class _InstitutionDetailPageState extends State<InstitutionDetailPage> {
late Future<Institution> future = widget.dataSource.institutionDetail(widget.institutionId);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('机构主页')),
body: FutureBuilder<Institution>(
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),
),
],
);
},
),
);
}
}
@@ -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<InstitutionsPage> createState() => _InstitutionsPageState();
}
class _InstitutionsPageState extends State<InstitutionsPage> {
late Future<List<Institution>> future = widget.dataSource.institutions();
@override
Widget build(BuildContext context) {
return FutureBuilder<List<Institution>>(
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 <Institution>[]]..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),
],
),
],
),
);
}
}
+70
View File
@@ -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<ListenPage> createState() => _ListenPageState();
}
class _ListenPageState extends State<ListenPage> {
late Future<List<AudioItem>> future = widget.dataSource.listen();
@override
Widget build(BuildContext context) {
return FutureBuilder<List<AudioItem>>(
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),
],
],
);
},
);
}
}
+80
View File
@@ -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),
],
),
),
);
}
}
+157
View File
@@ -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<ReportsPage> createState() => _ReportsPageState();
}
class _ReportsPageState extends State<ReportsPage> {
late Future<List<ReportCardModel>> future = widget.dataSource.reports();
String query = '';
String topic = '';
bool hasAudio = false;
@override
Widget build(BuildContext context) {
return FutureBuilder<List<ReportCardModel>>(
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<ReportCardModel> applyFilters(List<ReportCardModel> 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<void>(
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);
}
}
@@ -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);
}
}
+152
View File
@@ -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<ShellPage> createState() => _ShellPageState();
}
class _ShellPageState extends State<ShellPage> {
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: '我的'),
],
),
],
),
);
}
}
+12
View File
@@ -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()));
}
+47
View File
@@ -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,
),
),
);
}
+98
View File
@@ -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,
),
),
);
}
+71
View File
@@ -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',
];
+46
View File
@@ -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 }
+53
View File
@@ -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,
);
}
}
+79
View File
@@ -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(),
);
}
}
+188
View File
@@ -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,
),
],
),
);
}
}
+86
View File
@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import '../theme/wise_tokens.dart';
import 'app_buttons.dart';
import 'states.dart';
Future<void> showLoginSheet(BuildContext context, {String reason = '登录后保存当前动作'}) {
return showModalBottomSheet<void>(
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<void> showOutboundSheet(BuildContext context, {required String title}) {
return showModalBottomSheet<void>(
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, '外跳事件接口待接入');
},
),
],
),
),
);
}
+135
View File
@@ -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)),
),
);
}
+245
View File
@@ -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"
+90
View File
@@ -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
+161
View File
@@ -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<List<ReportCardModel>> recommended() async => [report];
@override
Future<List<ReportCardModel>> reports() async => [report];
@override
Future<List<Institution>> institutions() async => [institution];
@override
Future<Institution> 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<List<AudioItem>> listen() async => [
AudioItem(
audioId: 'aud_ssga_gold',
titleCn: '金价新高之后,谁在继续买?',
reportTitleCn: report.titleCn,
durationSec: 180,
reportId: report.id,
institution: institution,
),
];
@override
Future<ReportDetail> 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> 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',
);
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+46
View File
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html>
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="研听 - 全球机构研报中文解读 App">
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="研听">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>研听</title>
<link rel="manifest" href="manifest.json">
</head>
<body>
<!--
You can customize the "flutter_bootstrap.js" script.
This is useful to provide a custom configuration to the Flutter loader
or to give the user feedback during the initialization process.
For more details:
* https://docs.flutter.dev/platform-integration/web/initialization
-->
<script src="flutter_bootstrap.js" async></script>
</body>
</html>
+35
View File
@@ -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"
}
]
}