commit 634ae98decb33b3d1970ebf786f38b864d54a939 Author: JIMME Date: Wed Jun 3 10:39:03 2026 +0900 chore: prepare yanting monorepo handoff diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..01371e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ +*.apk +build/verification/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..1e65aef --- /dev/null +++ b/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "924134a44c189315be2148659913dda1671cbe99" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 924134a44c189315be2148659913dda1671cbe99 + base_revision: 924134a44c189315be2148659913dda1671cbe99 + - platform: android + create_revision: 924134a44c189315be2148659913dda1671cbe99 + base_revision: 924134a44c189315be2148659913dda1671cbe99 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..ac366e7 --- /dev/null +++ b/NOTES.md @@ -0,0 +1,28 @@ +# report-notebooklm-app NOTES + +## 2026-06-03 + +- App prototype pass moved the Flutter client from a single `lib/main.dart` shell into feature/domain folders: + - `lib/theme/`: Wise-style tokens and `ThemeData`. + - `lib/data/`: typed API data source and models for reports, institutions, display modules, audio, and module detail. + - `lib/features/`: feed, reports, institutions, listen, profile, and detail pages. + - `lib/features/detail/modules/`: renderer registry and module detail routing. + - `lib/widgets/`: reusable cards, badges/chips, buttons, states, sheets, and mini/player UI. +- User-visible product name is `研听`; code/package/API identifiers remain `report-notebooklm` / `rnb`. +- Current UI uses real backend seed/API responses. Local UI mock is limited to blocked Phase 1 behaviors: login, save/favorite state, outbound confirmation, and audio progress without a real stream. +- Product decision: Phase 1 has no report-interpretation download feature. Do not show a top-level download icon, Detail download button, profile download record, download API, audio offline package, or download login trigger. Original reports are accessed only through source/outbound links from source/compliance surfaces. +- 2026-06-03 prototype feedback pass: + - Detail module cards are clickable as a whole and keep a `查看详情` affordance. + - Module detail pages should use vertical `subtitle + content` flows, not left/right comparison layouts. + - Report source, publisher parameters, copyright note, and disclaimer render in one `报告来源` card; do not re-add a separate `发布机构` card or bottom `来源与合规` card. + - `局限与疑问` shows the "需要继续验证" reminder once at the bottom, not after every paragraph. + - Custom overscroll feedback must move content over whitespace and rebound smoothly; do not stretch text or relayout cards during pull. + - If report detail does not open in an emulator or device, first verify that the backend is running and that `RNB_API_BASE` is reachable from that runtime. Host loopback, emulator, and physical-device networking are different environments. +- Known remaining gaps: + - Real audio stream: needs backend `/audio/{audio_id}/stream` or equivalent playable URL in `/listen`. + - Auth and personal state sync: favorites, history, saved listens, and playback progress remain local UI placeholders. + - Outbound events: confirmation sheet exists, but `POST /outbound/events` is not implemented. + - Production API domain: app still requires explicit `--dart-define=RNB_API_BASE=...`. + - Release signing: not configured beyond Flutter scaffold defaults. + - App icon/final brand visual: not finalized. + - Backend pagination/cache/OSS signed URL: list pagination is still seed-scale; Redis/signed URL policies remain backend work. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2a92677 --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# report-notebooklm-app + +Flutter client for the report-notebooklm Phase 1 app shell. + +The backend API lives in `../report-notebooklm-api/` in the same monorepo. API/data/content-pipeline details are documented there; this directory focuses on App handoff, UI state, build commands, and integration notes. + +## Read First + +- [docs/HANDOFF.md](docs/HANDOFF.md): current App state, implemented screens, placeholders, and next work. +- [docs/PROJECT_BRIEF.md](docs/PROJECT_BRIEF.md): product and Phase 1 scope snapshot. +- [docs/APP_RUNBOOK.md](docs/APP_RUNBOOK.md): Flutter version, local run, web build, Android debug build, and verification. +- [docs/API_CONTRACT_NOTES.md](docs/API_CONTRACT_NOTES.md): endpoints and fields consumed by the App. +- [docs/PROJECT_MAP.md](docs/PROJECT_MAP.md): source tree map. + +## Product Boundary + +This repo contains App code and an engineering handoff snapshot. It is not the product source of truth. + +Product SSOT: mall-docs report-notebooklm docs. Snapshot date: 2026-06-03. + +Use `report-notebooklm` and `rnb` for technical identifiers. The user-facing product name is `研听`. + +## Requirements + +- Flutter 3.44.1 / Dart 3.12.1 or compatible newer versions. +- A running backend that serves `/api/report-notebooklm/v1`. +- For Android builds: Android SDK, accepted licenses, and an emulator or device. + +## API Base URL + +The App intentionally has no built-in live API default. Pass the backend base URL explicitly: + +```bash +flutter run -d chrome --dart-define=RNB_API_BASE= +``` + +Android emulator: + +```bash +flutter run -d --dart-define=RNB_API_BASE= +``` + +Same-network Android device: + +```bash +flutter run -d --dart-define=RNB_API_BASE=http://:/api/report-notebooklm/v1 +``` + +Only use cleartext HTTP for debug builds. Release builds must use HTTPS. + +## Verify + +```bash +flutter analyze +flutter test +flutter build web --dart-define=RNB_API_BASE= +flutter build apk --debug --dart-define=RNB_API_BASE= +``` + +## Current App Scope + +Implemented: + +- Five bottom tabs: 推荐, 研报, 机构, 听单, 我的. +- API-backed feed, report list, institution list, listen list, institution detail, and report detail. +- Module renderer registry for inline and card-plus-page modules. +- Product display name `研听`. +- Local UI placeholders for login, favorite, outbound confirmation, and playback progress. + +Not implemented yet: + +- Real auth. +- Real favorite/history/saved-listen sync. +- Real playable audio stream. +- Real outbound event write. +- Production API domain. +- Release signing, final icon, and final store metadata. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be82512 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,11 @@ +/.gradle +/captures/ +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..ebddeb3 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.report_notebooklm_app" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.report_notebooklm_app" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..d16cdf3 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d68fb56 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/report_notebooklm_app/MainActivity.kt b/android/app/src/main/kotlin/com/example/report_notebooklm_app/MainActivity.kt new file mode 100644 index 0000000..4ab88e7 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/report_notebooklm_app/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.report_notebooklm_app + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..13372ae Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2d428bf --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..9d82f78 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..aec9973 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/docs/API_CONTRACT_NOTES.md b/docs/API_CONTRACT_NOTES.md new file mode 100644 index 0000000..eda8c74 --- /dev/null +++ b/docs/API_CONTRACT_NOTES.md @@ -0,0 +1,158 @@ +# App API Contract Notes + +This is a handoff snapshot, not the product SSOT. + +Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03. + +For full API/data details, read `../report-notebooklm-api/docs/API_AND_DATA.md` from the monorepo root. + +## Configuration + +The App reads the API base URL from: + +```text +RNB_API_BASE +``` + +If `RNB_API_BASE` is missing, live API requests throw an error. This is intentional to avoid silently pointing production builds at a debug backend. + +## Endpoints Currently Consumed + +| Method | Path | App use | +|---|---|---| +| `GET` | `/feed/recommended` | Recommended feed cards. | +| `GET` | `/reports` | Report list. | +| `GET` | `/reports/{report_id}` | Report detail skeleton and modules. | +| `GET` | `/reports/{report_id}/modules/{module_id}` | Module detail page content. | +| `GET` | `/institutions` | Institution list. | +| `GET` | `/institutions/{institution_id}` | Institution detail and recent reports. | +| `GET` | `/listen` | Listen list and mini-player entry. | + +## Response Fields Currently Used + +Report cards: + +- `report_id` +- `title_cn` +- `subtitle_cn` +- `one_liner` +- `institution` +- `topics` +- `released_at` +- `has_audio` +- `interpretation_label` +- `source_tier` +- `cache_version` + +Report detail: + +- `report_id` +- `title_cn` +- `subtitle_cn` +- `original_title` +- `one_liner` +- `institution` +- `source` +- `topics` +- `has_audio` +- `interpretation_label` +- `risk_disclaimer` +- `released_at` +- `cache_version` +- `modules` + +Display modules: + +- `module_id` +- `type` +- `layer` +- `render_mode` +- `has_detail_page` +- `is_publish_blocking` +- `requires_human_review` +- `sort_order` +- `title_cn` +- `content` +- `preview` +- `content_ref` +- `content_etag` + +Listen items: + +- `audio_id` +- `title_cn` +- `duration_sec` +- `report_id` +- `report_title_cn` +- `institution` +- `released_at` +- `cache_version` + +Institution: + +- `institution_id` +- `name_cn` +- `name_en` +- `institution_type` +- `source_tier` +- `website_url` +- `covered_topics` +- `report_count` +- `latest_report_at` +- `credibility_note` +- `intro_cn` +- `latest_report` +- `recent_reports` + +## Module Rendering Behavior + +- `render_mode=inline`: App renders `content` on the report detail page. +- `render_mode=card_plus_page`: App renders `preview` on the report detail page and opens module detail for full content. +- Unknown module `type` should not crash the App; it should fall back to a generic or hidden renderer. + +Current renderer coverage includes: + +- `basic_info` +- `executive_overview` +- `core_insights` +- `key_data` +- `source_compliance` +- `audio` +- `institution` +- `timeline` +- `study_guide` +- `structure_graph` +- `related_sources` +- `differentiated_view` +- `weaknesses` + +## Endpoints Not Yet Consumed + +- `GET /audio/{audio_id}/stream` +- Auth endpoints. +- `/me` personal-state endpoints. +- `POST /outbound/events` + +The UI currently uses placeholders for these blocked flows: + +- Login prompt. +- Favorite action. +- Browse/history sync. +- Saved listen. +- Playback progress. +- Outbound confirmation. +- Real audio playback. + +## Public Field Boundary + +The App must not rely on or display: + +- `display_version` +- module `version` +- raw artifact payload +- raw NotebookLM IDs +- private object-storage references +- local paths +- auth internals + +The App should use `cache_version` as the public cache/version signal. diff --git a/docs/APP_RUNBOOK.md b/docs/APP_RUNBOOK.md new file mode 100644 index 0000000..b52fc26 --- /dev/null +++ b/docs/APP_RUNBOOK.md @@ -0,0 +1,81 @@ +# App Runbook + +This is a handoff snapshot, not the product SSOT. + +Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03. + +## Requirements + +- Flutter 3.44.1 / Dart 3.12.1 or compatible newer versions. +- A backend API serving `/api/report-notebooklm/v1`. +- Android SDK and accepted licenses for Android builds. +- Chrome for web debug. + +Check tool versions: + +```bash +flutter --version +dart --version +flutter doctor -v +``` + +If dependency solving reports that Dart is older than required, switch to a Flutter SDK that includes Dart 3.12.1 or newer. + +## API Base URL + +The App requires an explicit API base URL: + +```bash +--dart-define=RNB_API_BASE= +``` + +Examples: + +```bash +RNB_API_BASE=http://:/api/report-notebooklm/v1 +RNB_API_BASE=https:///api/report-notebooklm/v1 +``` + +Use cleartext HTTP only for local debug builds. Release builds must use HTTPS. + +## Web Run + +```bash +flutter run -d chrome --dart-define=RNB_API_BASE= +``` + +## Android Emulator Run + +Start the backend on the host, then: + +```bash +flutter devices +flutter run -d --dart-define=RNB_API_BASE= +``` + +## Same-Network Android Device + +Start the backend on a host/port reachable from the device, then: + +```bash +flutter run -d --dart-define=RNB_API_BASE=http://:/api/report-notebooklm/v1 +``` + +Use this only for debug. Do not hardcode LAN IPs in source code. + +## Verify + +```bash +flutter analyze +flutter test +flutter build web --dart-define=RNB_API_BASE= +flutter build apk --debug --dart-define=RNB_API_BASE= +``` + +## Release Notes + +- Release builds must not allow cleartext traffic. +- Production API base must be passed explicitly. +- Release signing is not finalized in this scaffold. +- Final app icon and store metadata are not finalized. +- Do not commit build outputs, APKs, screenshots, `.dart_tool/`, or local IDE caches. diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md new file mode 100644 index 0000000..362acee --- /dev/null +++ b/docs/HANDOFF.md @@ -0,0 +1,79 @@ +# App Handoff + +This is a handoff snapshot, not the product SSOT. + +Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03. + +## Current State + +The App is a runnable Phase 1 Flutter shell connected to the backend public seed API. It is not production-ready yet. + +Implemented: + +- Five bottom tabs: 推荐, 研报, 机构, 听单, 我的. +- API client configured by `RNB_API_BASE`. +- API-backed recommended feed, report list, institution list, institution detail, listen list, report detail, and module detail. +- Report detail module renderer registry. +- Inline module rendering and card-plus-page module preview/detail flow. +- Local mini-player UI and player controls. +- Login, favorite, outbound, and playback placeholders that make blocked Phase 1 flows visible without pretending they are complete. +- Android platform scaffold and debug/release build compatibility. + +Not implemented: + +- Auth and real login state. +- Server-synced favorites, reading history, saved listens, and playback progress. +- Real audio stream from `/audio/{audio_id}/stream`. +- Outbound event write before external navigation. +- Production API domain. +- Release signing. +- Final app icon, final brand visuals, privacy copy, and store metadata. + +## Important Product Decisions Reflected in App + +- User-facing product name is `研听`. +- Technical identifiers stay `report-notebooklm` / `rnb`. +- Phase 1 has no download feature for report interpretation content, audio packages, or PDFs. +- Source access is through source/compliance or outbound surfaces, not in-product downloads. +- Guest users can browse public content and listen; login is only required for synchronized personal state. +- App must not call NotebookLM or any LLM to generate report content. + +## Source Tree Map + +| Path | Purpose | +|---|---| +| `lib/main.dart` | App entry point and API data source construction. | +| `lib/app.dart` | Material app and theme hookup. | +| `lib/data/api/` | Backend API client. | +| `lib/data/models/` | Typed response models. | +| `lib/features/shell_page.dart` | Bottom-tab shell and mini-player state. | +| `lib/features/feed/` | Recommended feed. | +| `lib/features/reports/` | Report list. | +| `lib/features/institutions/` | Institution list and detail. | +| `lib/features/listen/` | Listen list. | +| `lib/features/profile/` | Guest/login placeholder and personal-state entry points. | +| `lib/features/detail/` | Report detail. | +| `lib/features/detail/modules/` | Module renderer registry and module detail page. | +| `lib/widgets/` | Reusable UI components. | +| `lib/theme/` | Design tokens and Flutter theme. | +| `android/` | Android platform scaffold. | +| `test/` | Widget tests. | + +## Suggested Handoff Order + +1. Read `docs/PROJECT_BRIEF.md`. +2. Run the backend seed API from `report-notebooklm-api/`. +3. Read `docs/API_CONTRACT_NOTES.md`. +4. Run `flutter analyze` and `flutter test`. +5. Run the App against the backend with `RNB_API_BASE`. +6. Choose the next App/API integration item from the open gaps. + +## Next App Work + +- Wire real auth once backend auth exists. +- Replace favorite/history/saved-listen placeholders with API-backed state. +- Use backend audio stream endpoint for actual playback URL. +- Persist playback progress for logged-in users. +- Write outbound event before external navigation. +- Add production API config and release signing. +- Finalize app icon, privacy wording, store copy, and risk-disclaimer surfaces. diff --git a/docs/PROJECT_BRIEF.md b/docs/PROJECT_BRIEF.md new file mode 100644 index 0000000..1182cd5 --- /dev/null +++ b/docs/PROJECT_BRIEF.md @@ -0,0 +1,65 @@ +# Project Brief Snapshot + +This is a handoff snapshot, not the product SSOT. + +Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03. + +## Product + +`研听` is a Chinese research-report interpretation app for users who want to understand global institutional research with lower language and time barriers. It turns hard-to-read English research reports into structured Chinese reading and listening experiences. + +Technical identifiers remain `report-notebooklm` and `rnb`. Do not use the product display name in code identifiers, database schema names, Redis keys, object storage paths, or API prefixes. + +## Phase 1 Goals + +- Validate whether Chinese users will repeatedly consume global institutional research-report interpretations. +- Ship a complete first app experience for discovery, reading, listening, saving, and returning to reports. +- Establish a minimum loop from report sources to selection, NotebookLM-assisted interpretation, review, storage, API distribution, and app display. +- Keep source attribution and compliance clear: this is report interpretation and annotation, not investment advice. +- Keep the commercial app independent from any local-only Vision runtime. + +## Target Users + +- General Chinese users interested in macro, precious metals, commodities, energy, central banks, and cross-asset research. +- Light professional users who want overseas institutional views and original-source traceability, without trading advice. +- Commuting or fragmented-time users who want reports transformed into listenable content. + +Non-target users: professional terminal users, real-time trading-signal users, UGC/community users, and users expecting original investment recommendations. + +## Main Tabs + +| Tab | Phase 1 scope | Explicitly out of scope | +|---|---|---| +| 推荐 | Latest and curated report interpretations. | Ads, hard trading CTAs, real-time news flashes. | +| 研报 | All published report interpretations with basic filters. | Advanced investment terminal search. | +| 机构 | Institution list and institution report entry points. | Commercial institution ranking or onboarding backend. | +| 听单 | Reports that have audio form. | User-created podcasts, downloads, offline packages. | +| 我的 | Guest/login state, favorites, history, saved listening entry points. | Comments, UGC, paid membership, points. | + +## Phase 1 Must Do + +- Public browsing for recommended reports, report list, institutions, and listen list. +- Report detail pages with title, institution, publication/release data, source type, topics, summary, structured modules, source/compliance information, and favorite entry. +- Guest users can browse public content and fully listen to at least one episode. +- Logged-in users can synchronize favorites, reading history, saved listens, and playback progress. +- Published app responses must expose only reviewed display artifacts, not raw NotebookLM artifacts. +- Every report detail must preserve source attribution and risk disclaimer wording. + +## Phase 1 Must Not Do + +- No commercialization: no ads, paid unlock, membership, task wall, or points. +- No comments, community, UGC, or user-generated report interpretations. +- No investment advice, trading signals, buy/sell points, return promises, or portfolio recommendations. +- No original financial news, real-time reporting, or commentary positioned as original market views. +- No in-product downloads for interpretation content, audio packages, or PDFs. +- No long-term production dependency on a local Vision runtime, local path, local cache, or local account state. +- No App or server-side LLM rewriting of NotebookLM-native content into unsupported original copy. + +## Compliance Boundary + +- Positioning: research-report interpretation and annotation service. +- Content: Chinese interpretation of public or authorized institutional reports. +- Detail pages, agreements, and store metadata must state that content is not investment advice. +- Each item must show institution, source, publication time, and interpretation/source labels. +- Gray broker sources require special handling and human review before public release. +- Phase 1 does not open user content surfaces. diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md new file mode 100644 index 0000000..237df87 --- /dev/null +++ b/docs/PROJECT_MAP.md @@ -0,0 +1,75 @@ +# App Project Map + +This is a handoff snapshot, not the product SSOT. + +Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03. + +## Entry Points + +| Path | Purpose | +|---|---| +| `lib/main.dart` | Creates `RnbApiDataSource` and starts the App. | +| `lib/app.dart` | Defines `MaterialApp`, app title, theme, and shell page. | +| `lib/features/shell_page.dart` | Owns bottom navigation and mini-player state. | + +## Data Layer + +| Path | Purpose | +|---|---| +| `lib/data/api/report_data_source.dart` | API client interface and HTTP implementation. | +| `lib/data/models/models.dart` | Data models, JSON parsing helpers, and formatting helpers. | + +The data source currently performs GET requests only. Mutating APIs for auth, favorites, playback progress, and outbound events are not implemented. + +## Feature Pages + +| Path | Purpose | +|---|---| +| `lib/features/feed/feed_page.dart` | Recommended feed. | +| `lib/features/reports/reports_page.dart` | Report list and simple filters. | +| `lib/features/institutions/institutions_page.dart` | Institution list. | +| `lib/features/institutions/institution_detail_page.dart` | Institution detail and recent reports. | +| `lib/features/listen/listen_page.dart` | Listen list. | +| `lib/features/profile/profile_page.dart` | Guest/login placeholder and personal-state entries. | +| `lib/features/detail/report_detail_page.dart` | Report detail shell and source/compliance display. | +| `lib/features/detail/modules/renderer_registry.dart` | Module card rendering and module detail page rendering. | +| `lib/features/shared/report_card_widget.dart` | Shared report card. | + +## Shared UI + +| Path | Purpose | +|---|---| +| `lib/widgets/app_buttons.dart` | Buttons. | +| `lib/widgets/app_card.dart` | Cards. | +| `lib/widgets/badges.dart` | Badges and chips. | +| `lib/widgets/mini_player.dart` | Mini-player and placeholder player UI. | +| `lib/widgets/sheets.dart` | Login/outbound sheets and toasts. | +| `lib/widgets/states.dart` | Loading, skeleton, empty, and error states. | +| `lib/theme/app_theme.dart` | Flutter `ThemeData`. | +| `lib/theme/wise_tokens.dart` | Color, spacing, radius, motion, and shadow tokens. | + +## Platform + +| Path | Purpose | +|---|---| +| `android/` | Android scaffold. | +| `android/app/src/debug/AndroidManifest.xml` | Debug-only cleartext allowance. | +| `android/app/src/main/AndroidManifest.xml` | Main manifest; release should remain HTTPS-only. | +| `web/` | Flutter web scaffold. | + +## Tests + +| Path | Purpose | +|---|---| +| `test/widget_test.dart` | Smoke widget test with fake data source. | + +## Generated or Local Files + +Do not commit: + +- `build/` +- `.dart_tool/` +- APKs +- screenshots +- local IDE caches +- local environment files diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 0000000..bc3a79c --- /dev/null +++ b/lib/app.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; + +import 'data/api/report_data_source.dart'; +import 'features/shell_page.dart'; +import 'theme/app_theme.dart'; + +class MyApp extends StatelessWidget { + const MyApp({required this.dataSource, super.key}); + + final ReportDataSource dataSource; + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: '研听', + debugShowCheckedModeBanner: false, + theme: buildAppTheme(), + scrollBehavior: const WhitespaceStretchScrollBehavior(), + home: ShellPage(dataSource: dataSource), + ); + } +} + +class WhitespaceStretchScrollBehavior extends MaterialScrollBehavior { + const WhitespaceStretchScrollBehavior(); + + @override + Widget buildOverscrollIndicator( + BuildContext context, + Widget child, + ScrollableDetails details, + ) { + return _WhitespaceStretchIndicator(child: child); + } +} + +class _WhitespaceStretchIndicator extends StatefulWidget { + const _WhitespaceStretchIndicator({required this.child}); + + final Widget child; + + @override + State<_WhitespaceStretchIndicator> createState() => + _WhitespaceStretchIndicatorState(); +} + +class _WhitespaceStretchIndicatorState + extends State<_WhitespaceStretchIndicator> + with SingleTickerProviderStateMixin { + static const double _maxStretch = 64; + static const double _dragResistance = 0.38; + + late final AnimationController _offsetController = + AnimationController.unbounded(vsync: this)..addListener(_onTick); + + @override + Widget build(BuildContext context) { + return NotificationListener( + onNotification: _handleScrollNotification, + child: ClipRect( + child: Transform.translate( + offset: Offset(0, _offsetController.value), + child: widget.child, + ), + ), + ); + } + + @override + void dispose() { + _offsetController.dispose(); + super.dispose(); + } + + bool _handleScrollNotification(ScrollNotification notification) { + if (notification.metrics.axis != Axis.vertical) { + return false; + } + if (notification is OverscrollNotification) { + final overscroll = notification.overscroll; + final atTop = + notification.metrics.pixels <= notification.metrics.minScrollExtent; + final atBottom = + notification.metrics.pixels >= notification.metrics.maxScrollExtent; + if (atTop && overscroll < 0) { + _setOffset( + (_offsetController.value - overscroll * _dragResistance).clamp( + 0, + _maxStretch, + ), + ); + } else if (atBottom && overscroll > 0) { + _setOffset( + (_offsetController.value - overscroll * _dragResistance).clamp( + -_maxStretch, + 0, + ), + ); + } + } + if (notification is ScrollUpdateNotification && + notification.dragDetails == null) { + _releaseOffset(); + } + if (notification is ScrollEndNotification) { + _releaseOffset(); + } + return false; + } + + void _setOffset(num next) { + if (next == _offsetController.value) { + return; + } + _offsetController.stop(); + _offsetController.value = next.toDouble(); + } + + void _releaseOffset() { + if (_offsetController.value == 0) { + return; + } + _offsetController.animateTo( + 0, + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ); + } + + void _onTick() { + if (mounted) { + setState(() {}); + } + } +} diff --git a/lib/data/api/report_data_source.dart b/lib/data/api/report_data_source.dart new file mode 100644 index 0000000..6953024 --- /dev/null +++ b/lib/data/api/report_data_source.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../models/models.dart'; + +abstract class ReportDataSource { + Future> recommended(); + Future> reports(); + Future> institutions(); + Future institutionDetail(String institutionId); + Future> listen(); + Future reportDetail(String reportId); + Future moduleDetail(String reportId, String moduleId); +} + +class RnbApiDataSource implements ReportDataSource { + RnbApiDataSource({ + http.Client? client, + this.baseUrl = const String.fromEnvironment('RNB_API_BASE'), + }) : client = client ?? http.Client(); + + final http.Client client; + final String baseUrl; + + Future _get(String path) async { + if (baseUrl.isEmpty) { + throw StateError('RNB_API_BASE is required for live API requests.'); + } + final response = await client.get(Uri.parse('$baseUrl$path')); + if (response.statusCode != 200) { + throw StateError('Request failed: ${response.statusCode}'); + } + return jsonDecode(utf8.decode(response.bodyBytes)) as JsonMap; + } + + List _items(JsonMap body) => asMapList(body['items']); + + @override + Future> recommended() async { + final body = await _get('/feed/recommended'); + return _items(body).map(ReportCardModel.fromJson).toList(); + } + + @override + Future> reports() async { + final body = await _get('/reports'); + return _items(body).map(ReportCardModel.fromJson).toList(); + } + + @override + Future> institutions() async { + final body = await _get('/institutions'); + return _items(body).map(Institution.fromJson).toList(); + } + + @override + Future institutionDetail(String institutionId) async { + return Institution.fromJson(await _get('/institutions/$institutionId')); + } + + @override + Future> listen() async { + final body = await _get('/listen'); + return _items(body).map(AudioItem.fromJson).toList(); + } + + @override + Future reportDetail(String reportId) async { + return ReportDetail.fromJson(await _get('/reports/$reportId')); + } + + @override + Future moduleDetail(String reportId, String moduleId) async { + return ModuleDetail.fromJson(await _get('/reports/$reportId/modules/$moduleId')); + } +} diff --git a/lib/data/models/models.dart b/lib/data/models/models.dart new file mode 100644 index 0000000..bafbaa5 --- /dev/null +++ b/lib/data/models/models.dart @@ -0,0 +1,313 @@ +typedef JsonMap = Map; + +String asString(Object? value, [String fallback = '']) => + value == null ? fallback : value.toString(); + +int asInt(Object? value, [int fallback = 0]) { + if (value is int) return value; + if (value is num) return value.round(); + return int.tryParse(asString(value)) ?? fallback; +} + +bool asBool(Object? value) => value == true; + +List asStringList(Object? value) { + if (value is List) return value.map((item) => item.toString()).toList(); + return const []; +} + +JsonMap asMap(Object? value) { + if (value is Map) return value; + if (value is Map) return value.map((key, val) => MapEntry(key.toString(), val)); + return const {}; +} + +List asMapList(Object? value) { + if (value is List) return value.map(asMap).where((item) => item.isNotEmpty).toList(); + return const []; +} + +String formatDate(String? iso) { + if (iso == null || iso.isEmpty) return ''; + final parsed = DateTime.tryParse(iso); + if (parsed == null) return iso; + return '${parsed.year}年${parsed.month}月${parsed.day}日'; +} + +String formatDuration(int seconds) { + final safe = seconds < 0 ? 0 : seconds; + final minutes = safe ~/ 60; + final secs = safe % 60; + return '$minutes:${secs.toString().padLeft(2, '0')}'; +} + +class Institution { + const Institution({ + required this.id, + required this.nameCn, + this.nameEn = '', + this.institutionType = '', + this.sourceTier = '', + this.websiteUrl = '', + this.coveredTopics = const [], + this.reportCount = 0, + this.latestReportAt, + this.credibilityNote = '', + this.introCn = '', + this.recentReports = const [], + }); + + final String id; + final String nameCn; + final String nameEn; + final String institutionType; + final String sourceTier; + final String websiteUrl; + final List coveredTopics; + final int reportCount; + final String? latestReportAt; + final String credibilityNote; + final String introCn; + final List recentReports; + + factory Institution.fromJson(JsonMap json) { + return Institution( + id: asString(json['institution_id']), + nameCn: asString(json['name_cn']), + nameEn: asString(json['name_en']), + institutionType: asString(json['institution_type']), + sourceTier: asString(json['source_tier']), + websiteUrl: asString(json['website_url']), + coveredTopics: asStringList(json['covered_topics']), + reportCount: asInt(json['report_count']), + latestReportAt: json['latest_report_at']?.toString(), + credibilityNote: asString(json['credibility_note']), + introCn: asString(json['intro_cn']), + recentReports: asMapList(json['recent_reports']) + .map(ReportCardModel.fromJson) + .toList(), + ); + } +} + +class ReportCardModel { + const ReportCardModel({ + required this.id, + required this.titleCn, + required this.institution, + this.subtitleCn = '', + this.oneLiner = '', + this.topics = const [], + this.releasedAt, + this.hasAudio = false, + this.interpretationLabel = '研报解读', + this.sourceTier = '', + this.cacheVersion = '', + }); + + final String id; + final String titleCn; + final String subtitleCn; + final String oneLiner; + final Institution institution; + final List topics; + final String? releasedAt; + final bool hasAudio; + final String interpretationLabel; + final String sourceTier; + final String cacheVersion; + + factory ReportCardModel.fromJson(JsonMap json) { + final institution = Institution.fromJson(asMap(json['institution'])); + return ReportCardModel( + id: asString(json['report_id']), + titleCn: asString(json['title_cn']), + subtitleCn: asString(json['subtitle_cn']), + oneLiner: asString(json['one_liner']), + institution: institution, + topics: asStringList(json['topics']), + releasedAt: json['released_at']?.toString(), + hasAudio: asBool(json['has_audio']), + interpretationLabel: asString(json['interpretation_label'], '研报解读'), + sourceTier: asString(json['source_tier']), + cacheVersion: asString(json['cache_version']), + ); + } +} + +class AudioItem { + const AudioItem({ + required this.audioId, + required this.reportId, + required this.titleCn, + required this.reportTitleCn, + required this.durationSec, + required this.institution, + this.releasedAt, + this.cacheVersion = '', + }); + + final String audioId; + final String reportId; + final String titleCn; + final String reportTitleCn; + final int durationSec; + final Institution institution; + final String? releasedAt; + final String cacheVersion; + + factory AudioItem.fromJson(JsonMap json) { + return AudioItem( + audioId: asString(json['audio_id']), + reportId: asString(json['report_id']), + titleCn: asString(json['title_cn']), + reportTitleCn: asString(json['report_title_cn'], asString(json['title_cn'])), + durationSec: asInt(json['duration_sec']), + institution: Institution.fromJson(asMap(json['institution'])), + releasedAt: json['released_at']?.toString(), + cacheVersion: asString(json['cache_version']), + ); + } +} + +class DisplayModule { + const DisplayModule({ + required this.id, + required this.type, + required this.titleCn, + required this.renderMode, + this.layer = '', + this.hasDetailPage = false, + this.sortOrder = 0, + this.content = const {}, + this.preview = const {}, + this.contentRef = '', + this.contentEtag = '', + }); + + final String id; + final String type; + final String titleCn; + final String layer; + final String renderMode; + final bool hasDetailPage; + final int sortOrder; + final JsonMap content; + final JsonMap preview; + final String contentRef; + final String contentEtag; + + factory DisplayModule.fromJson(JsonMap json) { + return DisplayModule( + id: asString(json['module_id']), + type: asString(json['type']), + titleCn: asString(json['title_cn'], asString(json['type'])), + layer: asString(json['layer']), + renderMode: asString(json['render_mode'], 'inline'), + hasDetailPage: asBool(json['has_detail_page']), + sortOrder: asInt(json['sort_order']), + content: asMap(json['content']), + preview: asMap(json['preview']), + contentRef: asString(json['content_ref']), + contentEtag: asString(json['content_etag']), + ); + } +} + +class ReportDetail { + const ReportDetail({ + required this.id, + required this.titleCn, + required this.institution, + this.subtitleCn = '', + this.originalTitle = '', + this.oneLiner = '', + this.source = const {}, + this.topics = const [], + this.hasAudio = false, + this.interpretationLabel = '研报解读', + this.riskDisclaimer = '', + this.releasedAt, + this.cacheVersion = '', + this.modules = const [], + }); + + final String id; + final String titleCn; + final String subtitleCn; + final String originalTitle; + final String oneLiner; + final Institution institution; + final JsonMap source; + final List topics; + final bool hasAudio; + final String interpretationLabel; + final String riskDisclaimer; + final String? releasedAt; + final String cacheVersion; + final List modules; + + factory ReportDetail.fromJson(JsonMap json) { + return ReportDetail( + id: asString(json['report_id']), + titleCn: asString(json['title_cn']), + subtitleCn: asString(json['subtitle_cn']), + originalTitle: asString(json['original_title']), + oneLiner: asString(json['one_liner']), + institution: Institution.fromJson(asMap(json['institution'])), + source: asMap(json['source']), + topics: asStringList(json['topics']), + hasAudio: asBool(json['has_audio']), + interpretationLabel: asString(json['interpretation_label'], '研报解读'), + riskDisclaimer: asString(json['risk_disclaimer']), + releasedAt: json['released_at']?.toString(), + cacheVersion: asString(json['cache_version']), + modules: asMapList(json['modules']).map(DisplayModule.fromJson).toList(), + ); + } + + ReportCardModel asCard() { + return ReportCardModel( + id: id, + titleCn: titleCn, + subtitleCn: subtitleCn, + oneLiner: oneLiner, + institution: institution, + topics: topics, + releasedAt: releasedAt, + hasAudio: hasAudio, + interpretationLabel: interpretationLabel, + sourceTier: asString(source['source_tier']), + cacheVersion: cacheVersion, + ); + } +} + +class ModuleDetail { + const ModuleDetail({ + required this.id, + required this.type, + required this.titleCn, + this.content = const {}, + this.contentEtag = '', + this.cacheVersion = '', + }); + + final String id; + final String type; + final String titleCn; + final JsonMap content; + final String contentEtag; + final String cacheVersion; + + factory ModuleDetail.fromJson(JsonMap json) { + return ModuleDetail( + id: asString(json['module_id']), + type: asString(json['type']), + titleCn: asString(json['title_cn'], asString(json['type'])), + content: asMap(json['content']), + contentEtag: asString(json['content_etag']), + cacheVersion: asString(json['cache_version']), + ); + } +} diff --git a/lib/features/detail/modules/renderer_registry.dart b/lib/features/detail/modules/renderer_registry.dart new file mode 100644 index 0000000..b988dd1 --- /dev/null +++ b/lib/features/detail/modules/renderer_registry.dart @@ -0,0 +1,993 @@ +import 'package:flutter/material.dart'; + +import '../../../data/api/report_data_source.dart'; +import '../../../data/models/models.dart'; +import '../../../theme/wise_tokens.dart'; +import '../../../widgets/app_card.dart'; +import '../../../widgets/badges.dart'; +import '../../../widgets/mini_player.dart'; + +typedef StartModuleAudio = + void Function( + String audioId, + String reportId, + String title, + int durationSec, + ); + +class ModuleRendererRegistry { + const ModuleRendererRegistry(); + + Widget card({ + required BuildContext context, + required DisplayModule module, + required ReportDetail report, + required ReportDataSource dataSource, + required PlayerStateModel player, + StartModuleAudio? onStartAudio, + VoidCallback? onToggleAudio, + void Function(int delta)? onSeekAudio, + VoidCallback? onSpeed, + }) { + final openDetail = module.hasDetailPage + ? () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ModuleDetailPage( + reportId: report.id, + module: module, + report: report, + dataSource: dataSource, + registry: this, + ), + ), + ) + : null; + return AppCard( + onTap: openDetail, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ModuleHeader(module: module), + const SizedBox(height: WiseSpacing.x4), + _contentFor( + context, + type: module.type, + payload: module.renderMode == 'inline' + ? module.content + : module.preview, + report: report, + player: player, + onStartAudio: onStartAudio, + onToggleAudio: onToggleAudio, + onSeekAudio: onSeekAudio, + onSpeed: onSpeed, + compact: module.renderMode != 'inline', + ), + if (module.hasDetailPage) ...[ + const SizedBox(height: WiseSpacing.x4), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: openDetail, + icon: const Icon(Icons.open_in_new), + label: const Text('查看详情'), + ), + ), + ], + ], + ), + ); + } + + Widget page( + BuildContext context, + String type, + JsonMap payload, { + ReportDetail? report, + }) { + return _contentFor( + context, + type: type, + payload: payload, + report: report, + player: const PlayerStateModel(), + compact: false, + ); + } + + Widget _contentFor( + BuildContext context, { + required String type, + required JsonMap payload, + required PlayerStateModel player, + ReportDetail? report, + StartModuleAudio? onStartAudio, + VoidCallback? onToggleAudio, + void Function(int delta)? onSeekAudio, + VoidCallback? onSpeed, + bool compact = false, + }) { + return switch (type) { + 'basic_info' => _BasicInfo(payload: payload, report: report), + 'core_insights' => _CoreInsights(payload: payload), + 'source_compliance' => _SourceCompliance( + payload: payload, + report: report, + ), + 'audio' => _AudioModule( + payload: payload, + report: report, + player: player, + onStartAudio: onStartAudio, + onToggleAudio: onToggleAudio, + onSeekAudio: onSeekAudio, + onSpeed: onSpeed, + ), + 'institution' => _InstitutionModule(payload: payload, report: report), + 'executive_overview' => _SectionsModule( + payload: payload, + compact: compact, + ), + 'key_data' => _KeyDataModule(payload: payload, compact: compact), + 'timeline' => _TimelineModule(payload: payload, compact: compact), + 'study_guide' => _StudyGuideModule(payload: payload, compact: compact), + 'structure_graph' => _StructureGraphModule( + payload: payload, + compact: compact, + ), + 'related_sources' => _RelatedSourcesModule( + payload: payload, + compact: compact, + ), + 'differentiated_view' => _DifferentiatedViewModule( + payload: payload, + compact: compact, + ), + 'weaknesses' => _WeaknessesModule(payload: payload, compact: compact), + 'infographic' => _FallbackModule(type: '信息图', payload: payload), + 'research_discovery' => _FallbackModule(type: '延伸研究', payload: payload), + _ => _FallbackModule(type: type, payload: payload), + }; + } +} + +class ModuleDetailPage extends StatefulWidget { + const ModuleDetailPage({ + required this.reportId, + required this.module, + required this.report, + required this.dataSource, + required this.registry, + super.key, + }); + + final String reportId; + final DisplayModule module; + final ReportDetail report; + final ReportDataSource dataSource; + final ModuleRendererRegistry registry; + + @override + State createState() => _ModuleDetailPageState(); +} + +class _ModuleDetailPageState extends State { + late Future future = widget.dataSource.moduleDetail( + widget.reportId, + widget.module.id, + ); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(widget.module.titleCn)), + body: FutureBuilder( + future: future, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center( + child: Text( + snapshot.error.toString(), + textAlign: TextAlign.center, + ), + ); + } + final detail = snapshot.data!; + return ListView( + padding: const EdgeInsets.all(WiseSpacing.x4), + children: [ + AppCard( + child: widget.registry.page( + context, + detail.type, + detail.content, + report: widget.report, + ), + ), + const SizedBox(height: WiseSpacing.x3), + Text( + '缓存版本 ${detail.cacheVersion}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ); + }, + ), + ); + } +} + +class _ModuleHeader extends StatelessWidget { + const _ModuleHeader({required this.module}); + + final DisplayModule module; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Text( + module.titleCn, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + if (module.layer.isNotEmpty) + AppBadge(text: module.layer.toUpperCase(), kind: BadgeKind.brand), + ], + ); + } +} + +class _BasicInfo extends StatelessWidget { + const _BasicInfo({required this.payload, required this.report}); + + final JsonMap payload; + final ReportDetail? report; + + @override + Widget build(BuildContext context) { + final topics = asStringList(payload['topics']).isEmpty + ? report?.topics ?? const [] + : asStringList(payload['topics']); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + asString( + payload['summary_cn'], + asString(payload['scope_cn'], report?.oneLiner ?? ''), + ), + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: WiseSpacing.x2), + Wrap( + spacing: WiseSpacing.x2, + runSpacing: WiseSpacing.x2, + children: [ + for (final topic in topics) AppBadge(text: topic), + if (report?.releasedAt != null) + AppBadge(text: formatDate(report!.releasedAt)), + ], + ), + ], + ); + } +} + +class _CoreInsights extends StatelessWidget { + const _CoreInsights({required this.payload}); + + final JsonMap payload; + + @override + Widget build(BuildContext context) { + final points = asMapList(payload['points']); + if (points.isEmpty) return _TextLines(payload: payload); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final point in points) + Padding( + padding: const EdgeInsets.only(bottom: WiseSpacing.x3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppBadge( + text: _kindLabel(asString(point['kind'])), + kind: _kindBadge(asString(point['kind'])), + ), + const SizedBox(height: WiseSpacing.x1), + Text( + asString(point['text']), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ], + ); + } +} + +class _SourceCompliance extends StatelessWidget { + const _SourceCompliance({required this.payload, required this.report}); + + final JsonMap payload; + final ReportDetail? report; + + @override + Widget build(BuildContext context) { + final institution = report?.institution; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (asString(payload['source_note']).isNotEmpty) + Text( + asString(payload['source_note']), + style: Theme.of(context).textTheme.bodyMedium, + ), + if (institution != null) ...[ + const SizedBox(height: WiseSpacing.x4), + Text('发布机构', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: WiseSpacing.x2), + _InfoLine(label: '机构名称', value: institution.nameCn), + if (institution.nameEn.isNotEmpty) + _InfoLine(label: '英文名称', value: institution.nameEn), + if (institution.institutionType.isNotEmpty) + _InfoLine( + label: '机构类型', + value: _institutionTypeLabel(institution.institutionType), + ), + if (institution.sourceTier.isNotEmpty) + _InfoLine(label: '来源层级', value: institution.sourceTier), + if (institution.reportCount > 0) + _InfoLine(label: '收录报告', value: '${institution.reportCount} 份'), + if (institution.coveredTopics.isNotEmpty) + _InfoLine( + label: '覆盖主题', + value: institution.coveredTopics.join('、'), + ), + if (institution.websiteUrl.isNotEmpty) + _InfoLine(label: '官网', value: institution.websiteUrl), + if (institution.introCn.isNotEmpty) + _InfoLine(label: '说明', value: institution.introCn), + ], + if (asString(payload['copyright_cn']).isNotEmpty) ...[ + const SizedBox(height: WiseSpacing.x4), + Text( + asString(payload['copyright_cn']), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + const SizedBox(height: WiseSpacing.x3), + DecoratedBox( + decoration: BoxDecoration( + color: const Color(0x109A6500), + borderRadius: BorderRadius.circular(WiseRadius.sm), + ), + child: Padding( + padding: const EdgeInsets.all(WiseSpacing.x3), + child: Text( + asString(payload['disclaimer'], '本内容为公开/授权研报的结构化解读,不构成投资建议。'), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: WiseColors.warning), + ), + ), + ), + ], + ); + } +} + +class _InfoLine extends StatelessWidget { + const _InfoLine({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + if (value.isEmpty) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(bottom: WiseSpacing.x2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: WiseColors.ink700), + ), + const SizedBox(height: WiseSpacing.x1), + Text(value, style: Theme.of(context).textTheme.bodyMedium), + ], + ), + ); + } +} + +class _AudioModule extends StatelessWidget { + const _AudioModule({ + required this.payload, + required this.report, + required this.player, + this.onStartAudio, + this.onToggleAudio, + this.onSeekAudio, + this.onSpeed, + }); + + final JsonMap payload; + final ReportDetail? report; + final PlayerStateModel player; + final StartModuleAudio? onStartAudio; + final VoidCallback? onToggleAudio; + final void Function(int delta)? onSeekAudio; + final VoidCallback? onSpeed; + + @override + Widget build(BuildContext context) { + final title = asString(payload['title_cn'], report?.titleCn ?? '音频解读'); + final audioId = asString( + payload['audio_id'], + 'local_${report?.id ?? title.hashCode}', + ); + final duration = asInt(payload['duration_sec'], 180); + return PlayerCard( + title: title, + durationSec: duration, + player: player, + onStart: () => + onStartAudio?.call(audioId, report?.id ?? '', title, duration), + onToggle: onToggleAudio ?? () {}, + onSeek: onSeekAudio ?? (_) {}, + onSpeed: onSpeed ?? () {}, + ); + } +} + +class _InstitutionModule extends StatelessWidget { + const _InstitutionModule({required this.payload, required this.report}); + + final JsonMap payload; + final ReportDetail? report; + + @override + Widget build(BuildContext context) { + final name = asString(payload['name_cn'], report?.institution.nameCn ?? ''); + return Row( + children: [ + const Icon(Icons.account_balance_outlined, color: WiseColors.primary), + const SizedBox(width: WiseSpacing.x2), + Expanded( + child: Text(name, style: Theme.of(context).textTheme.bodyMedium), + ), + Text( + '${asInt(payload['report_count'], report?.institution.reportCount ?? 0)} 份', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ); + } +} + +class _SectionsModule extends StatelessWidget { + const _SectionsModule({required this.payload, required this.compact}); + + final JsonMap payload; + final bool compact; + + @override + Widget build(BuildContext context) { + final summary = asString( + payload['preview_summary'], + asString(payload['intro_cn']), + ); + final sections = asMapList(payload['sections']); + if (compact) return _Preview(payload: payload); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (summary.isNotEmpty) + Text(summary, style: Theme.of(context).textTheme.bodyMedium), + for (final section in sections) ...[ + const SizedBox(height: WiseSpacing.x3), + Text( + asString(section['heading']), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: WiseSpacing.x1), + Text( + asString(section['body']), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ], + ); + } +} + +class _KeyDataModule extends StatelessWidget { + const _KeyDataModule({required this.payload, required this.compact}); + + final JsonMap payload; + final bool compact; + + @override + Widget build(BuildContext context) { + if (compact) return _Preview(payload: payload); + final rows = asMapList(payload['rows']); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final row in rows) + Padding( + padding: const EdgeInsets.only(bottom: WiseSpacing.x4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + asString(row['metric']), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: WiseSpacing.x1), + Text( + _valueWithUnit(row), + style: Theme.of(context).textTheme.bodyLarge, + ), + if (asString( + row['judgment'], + asString(row['importance']), + ).isNotEmpty) ...[ + const SizedBox(height: WiseSpacing.x1), + Text( + asString(row['judgment'], asString(row['importance'])), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + if (asString(row['importance']).isNotEmpty && + asString(row['importance']) != + asString(row['judgment'])) ...[ + const SizedBox(height: WiseSpacing.x1), + Text( + asString(row['importance']), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ], + ), + ), + ], + ); + } +} + +class _TimelineModule extends StatelessWidget { + const _TimelineModule({required this.payload, required this.compact}); + + final JsonMap payload; + final bool compact; + + @override + Widget build(BuildContext context) { + if (compact) return _Preview(payload: payload); + final events = asMapList(payload['events']); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final event in events) + Padding( + padding: const EdgeInsets.only(bottom: WiseSpacing.x4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (asString(event['date']).isNotEmpty) + Text( + asString(event['date']), + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: WiseColors.primary), + ), + const SizedBox(height: WiseSpacing.x1), + Text( + asString(event['event']), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: WiseSpacing.x1), + Text( + asString(event['impact']), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ], + ); + } +} + +class _StudyGuideModule extends StatelessWidget { + const _StudyGuideModule({required this.payload, required this.compact}); + + final JsonMap payload; + final bool compact; + + @override + Widget build(BuildContext context) { + if (compact) return _Preview(payload: payload); + final faqs = asMapList(payload['faq_items']); + final glossary = asMapList(payload['glossary']); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (asString(payload['intro_cn']).isNotEmpty) + Text( + asString(payload['intro_cn']), + style: Theme.of(context).textTheme.bodyMedium, + ), + for (final item in faqs) + ExpansionTile( + tilePadding: EdgeInsets.zero, + title: Text(asString(item['question'])), + children: [ + Align( + alignment: Alignment.centerLeft, + child: Text( + asString(item['answer']), + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + if (glossary.isNotEmpty) ...[ + const SizedBox(height: WiseSpacing.x3), + Wrap( + spacing: WiseSpacing.x2, + runSpacing: WiseSpacing.x2, + children: [ + for (final item in glossary) + AppBadge( + text: + '${asString(item['term'])}: ${asString(item['definition'])}', + ), + ], + ), + ], + ], + ); + } +} + +class _StructureGraphModule extends StatelessWidget { + const _StructureGraphModule({required this.payload, required this.compact}); + + final JsonMap payload; + final bool compact; + + @override + Widget build(BuildContext context) { + if (compact) return _Preview(payload: payload); + final nodes = asMapList(payload['nodes']); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + asString(payload['root']), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: WiseSpacing.x3), + for (final node in nodes) + Padding( + padding: const EdgeInsets.only(bottom: WiseSpacing.x4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + asString(node['label']), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: WiseSpacing.x1), + for (final child in asStringList(node['children'])) + Padding( + padding: const EdgeInsets.only(bottom: WiseSpacing.x1), + child: Text( + child, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + ), + ], + ); + } +} + +class _RelatedSourcesModule extends StatelessWidget { + const _RelatedSourcesModule({required this.payload, required this.compact}); + + final JsonMap payload; + final bool compact; + + @override + Widget build(BuildContext context) { + final items = asMapList(payload['items'] ?? payload['sources']); + if (items.isEmpty) return _Preview(payload: payload); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final item in items) + Padding( + padding: const EdgeInsets.only(bottom: WiseSpacing.x4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + asString(item['title']), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: WiseSpacing.x1), + Text( + asString(item['summary_cn'], asString(item['source_name'])), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ], + ); + } +} + +class _DifferentiatedViewModule extends StatelessWidget { + const _DifferentiatedViewModule({ + required this.payload, + required this.compact, + }); + + final JsonMap payload; + final bool compact; + + @override + Widget build(BuildContext context) { + if (compact) return _Preview(payload: payload); + final items = asMapList(payload['divergences']); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final item in items) + Padding( + padding: const EdgeInsets.only(bottom: WiseSpacing.x4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + asString(item['topic']), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: WiseSpacing.x2), + if (asString(item['consensus_view']).isNotEmpty) ...[ + Text( + '常见观点', + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: WiseColors.ink700), + ), + const SizedBox(height: WiseSpacing.x1), + Text( + asString(item['consensus_view']), + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: WiseSpacing.x2), + ], + if (asString(item['report_position']).isNotEmpty) ...[ + Text( + '报告观点', + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: WiseColors.primary), + ), + const SizedBox(height: WiseSpacing.x1), + Text( + asString(item['report_position']), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ], + ), + ), + ], + ); + } +} + +class _WeaknessesModule extends StatelessWidget { + const _WeaknessesModule({required this.payload, required this.compact}); + + final JsonMap payload; + final bool compact; + + @override + Widget build(BuildContext context) { + if (compact) return _Preview(payload: payload); + final items = asMapList(payload['items']); + final verificationNotes = asStringList(payload['verification_notes']); + final counterEvidence = { + for (final item in items) + if (asString(item['counter_evidence']).isNotEmpty) + asString(item['counter_evidence']), + }.toList(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (asString(payload['disclaimer_cn']).isNotEmpty) + Text( + asString(payload['disclaimer_cn']), + style: Theme.of(context).textTheme.bodySmall, + ), + for (final item in items) + Padding( + padding: const EdgeInsets.only( + top: WiseSpacing.x3, + bottom: WiseSpacing.x2, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + asString(item['topic']), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: WiseSpacing.x1), + Text( + asString(item['weakness']), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + if (verificationNotes.isNotEmpty || counterEvidence.isNotEmpty) ...[ + const SizedBox(height: WiseSpacing.x2), + DecoratedBox( + decoration: BoxDecoration( + color: const Color(0x109A6500), + borderRadius: BorderRadius.circular(WiseRadius.sm), + ), + child: Padding( + padding: const EdgeInsets.all(WiseSpacing.x3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '需要继续验证', + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: WiseColors.warning), + ), + const SizedBox(height: WiseSpacing.x1), + for (final note + in verificationNotes.isNotEmpty + ? verificationNotes + : counterEvidence) + Padding( + padding: const EdgeInsets.only(bottom: WiseSpacing.x1), + child: Text( + note, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ), + ), + ], + ], + ); + } +} + +class _Preview extends StatelessWidget { + const _Preview({required this.payload}); + + final JsonMap payload; + + @override + Widget build(BuildContext context) { + final headline = asString( + payload['preview_headline'], + asString(payload['preview_summary'], asString(payload['root'])), + ); + final highlights = asStringList(payload['highlights']); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (headline.isNotEmpty) + Text(headline, style: Theme.of(context).textTheme.bodyMedium), + for (final item in highlights.take(3)) + Padding( + padding: const EdgeInsets.only(top: WiseSpacing.x1), + child: Text( + '• $item', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ); + } +} + +class _TextLines extends StatelessWidget { + const _TextLines({required this.payload}); + + final JsonMap payload; + + @override + Widget build(BuildContext context) { + final values = payload.entries + .where( + (entry) => entry.value != null && entry.value.toString().isNotEmpty, + ) + .map((entry) => '${entry.key}: ${entry.value}') + .take(5) + .join('\n'); + return Text( + values.isEmpty ? '该模块暂无可展示内容。' : values, + style: Theme.of(context).textTheme.bodyMedium, + ); + } +} + +class _FallbackModule extends StatelessWidget { + const _FallbackModule({required this.type, required this.payload}); + + final String type; + final JsonMap payload; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppBadge(text: '未知模块:$type', kind: BadgeKind.warning), + const SizedBox(height: WiseSpacing.x2), + _Preview(payload: payload), + ], + ); + } +} + +String _kindLabel(String kind) => switch (kind) { + 'view' => '观点', + 'number' => '数字', + 'risk' => '风险', + _ => '要点', +}; + +BadgeKind _kindBadge(String kind) => switch (kind) { + 'risk' => BadgeKind.warning, + 'number' => BadgeKind.audio, + _ => BadgeKind.brand, +}; + +String _valueWithUnit(JsonMap row) { + final value = asString(row['value']); + final unit = asString(row['unit']); + if (unit.isEmpty) return value; + return '$value $unit'; +} + +String _institutionTypeLabel(String value) => switch (value) { + 'international_org' => '国际组织', + 'official' => '官方机构', + 'industry_org' => '行业组织', + 'asset_manager' => '资管机构', + 'bank_research' => '银行研究', + 'partner' => '合作机构', + _ => value, +}; diff --git a/lib/features/detail/report_detail_page.dart b/lib/features/detail/report_detail_page.dart new file mode 100644 index 0000000..f173e2c --- /dev/null +++ b/lib/features/detail/report_detail_page.dart @@ -0,0 +1,199 @@ +import 'package:flutter/material.dart'; + +import '../../data/api/report_data_source.dart'; +import '../../data/models/models.dart'; +import '../../theme/wise_tokens.dart'; +import '../../widgets/app_buttons.dart'; +import '../../widgets/app_card.dart'; +import '../../widgets/badges.dart'; +import '../../widgets/mini_player.dart'; +import '../../widgets/sheets.dart'; +import '../../widgets/states.dart'; +import 'modules/renderer_registry.dart'; + +class ReportDetailPage extends StatefulWidget { + const ReportDetailPage({ + required this.reportId, + required this.dataSource, + this.player = const PlayerStateModel(), + this.onStartAudio, + this.onToggleAudio, + this.onSeekAudio, + this.onSpeed, + super.key, + }); + + final String reportId; + final ReportDataSource dataSource; + final PlayerStateModel player; + final void Function( + String audioId, + String reportId, + String title, + int durationSec, + )? + onStartAudio; + final VoidCallback? onToggleAudio; + final void Function(int delta)? onSeekAudio; + final VoidCallback? onSpeed; + + @override + State createState() => _ReportDetailPageState(); +} + +class _ReportDetailPageState extends State { + static const registry = ModuleRendererRegistry(); + late Future future = widget.dataSource.reportDetail( + widget.reportId, + ); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('研报详情')), + body: FutureBuilder( + future: future, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const LoadingState(); + } + if (snapshot.hasError) { + return ErrorState( + message: snapshot.error.toString(), + onRetry: () => setState( + () => future = widget.dataSource.reportDetail(widget.reportId), + ), + ); + } + final detail = snapshot.data!; + return ListView( + padding: const EdgeInsets.all(WiseSpacing.x4), + children: [ + AppCard( + color: WiseColors.secondary200, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: WiseSpacing.x2, + runSpacing: WiseSpacing.x2, + children: [ + AppBadge( + text: detail.interpretationLabel, + kind: BadgeKind.brand, + ), + if (detail.hasAudio) + const AppBadge( + text: '音频', + icon: Icons.graphic_eq, + kind: BadgeKind.audio, + ), + AppBadge( + text: asString(detail.source['source_tier']), + icon: Icons.verified_outlined, + kind: BadgeKind.tier, + ), + ], + ), + const SizedBox(height: WiseSpacing.x3), + Text( + detail.titleCn, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.headlineSmall, + ), + if (detail.oneLiner.isNotEmpty) ...[ + const SizedBox(height: WiseSpacing.x2), + Text( + detail.oneLiner, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: WiseSpacing.x3), + Text( + '${detail.institution.nameCn} · ${formatDate(detail.releasedAt)}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + const SizedBox(height: WiseSpacing.x4), + _ActionBar(detail: detail), + const SizedBox(height: WiseSpacing.x4), + _Toc(modules: detail.modules), + const SizedBox(height: WiseSpacing.x4), + for (final module in detail.modules) ...[ + registry.card( + context: context, + module: module, + report: detail, + dataSource: widget.dataSource, + player: widget.player, + onStartAudio: widget.onStartAudio, + onToggleAudio: widget.onToggleAudio, + onSeekAudio: widget.onSeekAudio, + onSpeed: widget.onSpeed, + ), + const SizedBox(height: WiseSpacing.x4), + ], + ], + ); + }, + ), + ); + } +} + +class _ActionBar extends StatelessWidget { + const _ActionBar({required this.detail}); + + final ReportDetail detail; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: AppButton( + label: '收藏', + icon: Icons.favorite_border, + kind: AppButtonKind.ghost, + onPressed: () => showLoginSheet(context, reason: '登录后保存到你的收藏'), + ), + ), + const SizedBox(width: WiseSpacing.x2), + Expanded( + child: AppButton( + label: '原文', + icon: Icons.open_in_new, + kind: AppButtonKind.ghost, + onPressed: () => showOutboundSheet(context, title: detail.titleCn), + ), + ), + ], + ); + } +} + +class _Toc extends StatelessWidget { + const _Toc({required this.modules}); + + final List modules; + + @override + Widget build(BuildContext context) { + if (modules.isEmpty) return const SizedBox.shrink(); + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final module in modules) + Padding( + padding: const EdgeInsets.only(right: WiseSpacing.x2), + child: AppBadge(text: module.titleCn, kind: BadgeKind.brand), + ), + ], + ), + ); + } +} diff --git a/lib/features/feed/feed_page.dart b/lib/features/feed/feed_page.dart new file mode 100644 index 0000000..7a6ea91 --- /dev/null +++ b/lib/features/feed/feed_page.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; + +import '../../data/api/report_data_source.dart'; +import '../../data/models/models.dart'; +import '../../routing/app_routes.dart'; +import '../../theme/wise_tokens.dart'; +import '../../widgets/badges.dart'; +import '../../widgets/mini_player.dart'; +import '../../widgets/states.dart'; +import '../shared/report_card_widget.dart'; + +class FeedPage extends StatefulWidget { + const FeedPage({ + required this.dataSource, + required this.onPlay, + this.player = const PlayerStateModel(), + this.onStartModuleAudio, + this.onToggleAudio, + this.onSeekAudio, + this.onSpeed, + super.key, + }); + + final ReportDataSource dataSource; + final void Function(AudioItem item) onPlay; + final PlayerStateModel player; + final void Function(String audioId, String reportId, String title, int durationSec)? onStartModuleAudio; + final VoidCallback? onToggleAudio; + final void Function(int delta)? onSeekAudio; + final VoidCallback? onSpeed; + + @override + State createState() => _FeedPageState(); +} + +class _FeedPageState extends State { + String topic = '全部'; + late Future> future = widget.dataSource.recommended(); + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: future, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) return const LoadingState(); + if (snapshot.hasError) return ErrorState(message: snapshot.error.toString(), onRetry: () => setState(() => future = widget.dataSource.recommended())); + final items = snapshot.data ?? const []; + final topics = ['全部', ...{for (final item in items) ...item.topics}]; + final visible = topic == '全部' ? items : items.where((item) => item.topics.contains(topic)).toList(); + if (items.isEmpty) return const EmptyState(title: '暂无可推荐的研报解读', message: '稍后再来看看最新内容'); + return ListView( + padding: const EdgeInsets.all(WiseSpacing.x4), + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final t in topics) + Padding( + padding: const EdgeInsets.only(right: WiseSpacing.x2), + child: AppChip(label: t, selected: t == topic, onTap: () => setState(() => topic = t)), + ), + ], + ), + ), + const SizedBox(height: WiseSpacing.x3), + if (visible.isEmpty) + EmptyState(title: '暂无可推荐的研报解读', message: '换个主题,或去研报页看看全部内容', icon: Icons.filter_alt_off) + else ...[ + ReportCardWidget( + report: visible.first, + hero: true, + onTap: () => openReportDetail( + context, + widget.dataSource, + visible.first, + player: widget.player, + onStartAudio: widget.onStartModuleAudio, + onToggleAudio: widget.onToggleAudio, + onSeekAudio: widget.onSeekAudio, + onSpeed: widget.onSpeed, + ), + onPlayTap: () => playFromReport(widget.onPlay, visible.first), + ), + const SizedBox(height: WiseSpacing.x5), + Text('最新解读', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: WiseSpacing.x3), + for (final report in visible.skip(1)) ...[ + ReportCardWidget( + report: report, + onTap: () => openReportDetail( + context, + widget.dataSource, + report, + player: widget.player, + onStartAudio: widget.onStartModuleAudio, + onToggleAudio: widget.onToggleAudio, + onSeekAudio: widget.onSeekAudio, + onSpeed: widget.onSpeed, + ), + onPlayTap: () => playFromReport(widget.onPlay, report), + ), + const SizedBox(height: WiseSpacing.x3), + ], + ], + ], + ); + }, + ); + } + + void playFromReport(void Function(AudioItem item) onPlay, ReportCardModel report) { + onPlay( + AudioItem( + audioId: 'local_${report.id}', + reportId: report.id, + titleCn: report.titleCn, + reportTitleCn: report.titleCn, + durationSec: 180, + institution: report.institution, + ), + ); + } +} diff --git a/lib/features/institutions/institution_detail_page.dart b/lib/features/institutions/institution_detail_page.dart new file mode 100644 index 0000000..fd7ec78 --- /dev/null +++ b/lib/features/institutions/institution_detail_page.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; + +import '../../data/api/report_data_source.dart'; +import '../../data/models/models.dart'; +import '../../routing/app_routes.dart'; +import '../../theme/wise_tokens.dart'; +import '../../widgets/app_buttons.dart'; +import '../../widgets/app_card.dart'; +import '../../widgets/badges.dart'; +import '../../widgets/sheets.dart'; +import '../../widgets/states.dart'; +import '../shared/report_card_widget.dart'; + +class InstitutionDetailPage extends StatefulWidget { + const InstitutionDetailPage({required this.institutionId, required this.dataSource, super.key}); + + final String institutionId; + final ReportDataSource dataSource; + + @override + State createState() => _InstitutionDetailPageState(); +} + +class _InstitutionDetailPageState extends State { + late Future future = widget.dataSource.institutionDetail(widget.institutionId); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('机构主页')), + body: FutureBuilder( + future: future, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) return const LoadingState(); + if (snapshot.hasError) return ErrorState(message: snapshot.error.toString(), onRetry: () => setState(() => future = widget.dataSource.institutionDetail(widget.institutionId))); + final item = snapshot.data!; + return ListView( + padding: const EdgeInsets.all(WiseSpacing.x4), + children: [ + AppCard( + color: WiseColors.secondary200, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.nameCn, style: Theme.of(context).textTheme.headlineSmall), + if (item.nameEn.isNotEmpty) Text(item.nameEn, style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: WiseSpacing.x3), + Wrap( + spacing: WiseSpacing.x2, + runSpacing: WiseSpacing.x2, + children: [ + AppBadge(text: item.sourceTier, icon: Icons.verified_outlined, kind: BadgeKind.tier), + AppBadge(text: '${item.reportCount} 份研报', kind: BadgeKind.brand), + for (final topic in item.coveredTopics) AppBadge(text: topic), + ], + ), + ], + ), + ), + const SizedBox(height: WiseSpacing.x3), + if (item.introCn.isNotEmpty) + AppCard(child: Text(item.introCn, style: Theme.of(context).textTheme.bodyMedium)), + const SizedBox(height: WiseSpacing.x3), + if (item.credibilityNote.isNotEmpty) + AppCard( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.verified_user_outlined, color: WiseColors.positive), + const SizedBox(width: WiseSpacing.x2), + Expanded(child: Text(item.credibilityNote, style: Theme.of(context).textTheme.bodyMedium)), + ], + ), + ), + const SizedBox(height: WiseSpacing.x5), + Text('最新研报', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: WiseSpacing.x3), + if (item.recentReports.isEmpty) + const EmptyState(title: '机构暂无研报', message: '稍后再试', icon: Icons.article_outlined) + else + for (final report in item.recentReports) ...[ + ReportCardWidget( + report: report, + onTap: () => openReportDetail(context, widget.dataSource, report), + ), + const SizedBox(height: WiseSpacing.x3), + ], + AppButton( + label: '了解相关服务', + icon: Icons.open_in_new, + kind: AppButtonKind.ghost, + expand: true, + onPressed: () => showOutboundSheet(context, title: item.nameCn), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/features/institutions/institutions_page.dart b/lib/features/institutions/institutions_page.dart new file mode 100644 index 0000000..9d160a3 --- /dev/null +++ b/lib/features/institutions/institutions_page.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; + +import '../../data/api/report_data_source.dart'; +import '../../data/models/models.dart'; +import '../../routing/app_routes.dart'; +import '../../theme/wise_tokens.dart'; +import '../../widgets/app_card.dart'; +import '../../widgets/badges.dart'; +import '../../widgets/states.dart'; + +class InstitutionsPage extends StatefulWidget { + const InstitutionsPage({required this.dataSource, super.key}); + + final ReportDataSource dataSource; + + @override + State createState() => _InstitutionsPageState(); +} + +class _InstitutionsPageState extends State { + late Future> future = widget.dataSource.institutions(); + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: future, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) return const LoadingState(); + if (snapshot.hasError) return ErrorState(message: snapshot.error.toString(), onRetry: () => setState(() => future = widget.dataSource.institutions())); + final items = [...snapshot.data ?? const []]..sort((a, b) => b.reportCount.compareTo(a.reportCount)); + if (items.isEmpty) return const EmptyState(title: '暂无机构信息', message: '稍后再试', icon: Icons.account_balance_outlined); + return ListView( + padding: const EdgeInsets.all(WiseSpacing.x4), + children: [ + Text('研报来源机构', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: WiseSpacing.x3), + for (final item in items) ...[ + InstitutionCard( + institution: item, + onTap: () => openInstitutionDetail(context, widget.dataSource, item.id), + ), + const SizedBox(height: WiseSpacing.x3), + ], + ], + ); + }, + ); + } +} + +class InstitutionCard extends StatelessWidget { + const InstitutionCard({required this.institution, required this.onTap, super.key}); + + final Institution institution; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final initials = institution.nameCn.isEmpty ? '研' : institution.nameCn.characters.take(2).toString(); + return AppCard( + onTap: onTap, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 25, + backgroundColor: WiseColors.secondary200, + foregroundColor: WiseColors.primary, + child: Text(initials, style: const TextStyle(fontWeight: FontWeight.w800)), + ), + const SizedBox(width: WiseSpacing.x3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(institution.nameCn, style: Theme.of(context).textTheme.titleMedium), + if (institution.nameEn.isNotEmpty) + Text(institution.nameEn, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall), + const SizedBox(height: WiseSpacing.x2), + Wrap( + spacing: WiseSpacing.x2, + runSpacing: WiseSpacing.x2, + children: [ + if (institution.institutionType.isNotEmpty) AppBadge(text: institution.institutionType), + for (final topic in institution.coveredTopics.take(3)) AppBadge(text: topic, kind: BadgeKind.brand), + ], + ), + ], + ), + ), + const SizedBox(width: WiseSpacing.x2), + Column( + children: [ + Text('${institution.reportCount}', style: Theme.of(context).textTheme.titleMedium?.copyWith(color: WiseColors.primary)), + Text('份研报', style: Theme.of(context).textTheme.bodySmall), + ], + ), + ], + ), + ); + } +} diff --git a/lib/features/listen/listen_page.dart b/lib/features/listen/listen_page.dart new file mode 100644 index 0000000..62ffbbf --- /dev/null +++ b/lib/features/listen/listen_page.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +import '../../data/api/report_data_source.dart'; +import '../../data/models/models.dart'; +import '../../theme/wise_tokens.dart'; +import '../../widgets/app_card.dart'; +import '../../widgets/states.dart'; + +class ListenPage extends StatefulWidget { + const ListenPage({required this.dataSource, required this.onPlay, super.key}); + + final ReportDataSource dataSource; + final void Function(AudioItem item) onPlay; + + @override + State createState() => _ListenPageState(); +} + +class _ListenPageState extends State { + late Future> future = widget.dataSource.listen(); + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: future, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) return const LoadingState(label: '正在加载听单'); + if (snapshot.hasError) return ErrorState(message: snapshot.error.toString(), onRetry: () => setState(() => future = widget.dataSource.listen())); + final items = snapshot.data ?? const []; + if (items.isEmpty) return const EmptyState(title: '暂无音频研报', message: '先去研报页看看图文解读', icon: Icons.headphones_outlined); + return ListView( + padding: const EdgeInsets.all(WiseSpacing.x4), + children: [ + Text('全站音频解读', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: WiseSpacing.x2), + Text('游客可完整收听;真实音频流待后端接入。', style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: WiseSpacing.x4), + for (final item in items) ...[ + AppCard( + onTap: () => widget.onPlay(item), + child: Row( + children: [ + IconButton.filled( + onPressed: () => widget.onPlay(item), + icon: const Icon(Icons.play_arrow), + style: IconButton.styleFrom(backgroundColor: WiseColors.primary, foregroundColor: Colors.white), + ), + const SizedBox(width: WiseSpacing.x3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.reportTitleCn, maxLines: 2, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleMedium), + Text('${item.institution.nameCn} · ${formatDuration(item.durationSec)}', style: Theme.of(context).textTheme.bodySmall), + const SizedBox(height: WiseSpacing.x2), + LinearProgressIndicator(value: 0, minHeight: 4, color: WiseColors.accent, backgroundColor: WiseColors.border), + ], + ), + ), + ], + ), + ), + const SizedBox(height: WiseSpacing.x3), + ], + ], + ); + }, + ); + } +} diff --git a/lib/features/profile/profile_page.dart b/lib/features/profile/profile_page.dart new file mode 100644 index 0000000..b4bb874 --- /dev/null +++ b/lib/features/profile/profile_page.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; + +import '../../data/api/report_data_source.dart'; +import '../../theme/wise_tokens.dart'; +import '../../widgets/app_buttons.dart'; +import '../../widgets/app_card.dart'; +import '../../widgets/sheets.dart'; +import '../../widgets/states.dart'; + +class ProfilePage extends StatelessWidget { + const ProfilePage({required this.dataSource, super.key}); + + final ReportDataSource dataSource; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(WiseSpacing.x4), + children: [ + AppCard( + color: WiseColors.secondary200, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('游客', style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: WiseSpacing.x2), + Text('浏览、阅读和完整收听不需要登录。收藏、历史同步和保存听单等待 auth 接口接入。', style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: WiseSpacing.x4), + AppButton( + label: '登录后保存个人状态', + icon: Icons.login, + onPressed: () => showLoginSheet(context), + ), + ], + ), + ), + const SizedBox(height: WiseSpacing.x4), + _ProfileRow(icon: Icons.favorite_border, title: '收藏研报', subtitle: '登录后同步收藏', onTap: () => showLoginSheet(context, reason: '登录后保存到你的收藏')), + _ProfileRow(icon: Icons.history, title: '浏览历史', subtitle: '本地历史占位,服务端同步待接入', onTap: () => showAppToast(context, '历史同步接口待接入')), + _ProfileRow(icon: Icons.playlist_add_check, title: '保存听单', subtitle: '登录后保存到你的听单', onTap: () => showLoginSheet(context, reason: '登录后保存到你的听单')), + _ProfileRow(icon: Icons.open_in_new, title: '了解研值相关服务', subtitle: '外跳前提示风险边界', onTap: () => showOutboundSheet(context, title: '研值相关服务')), + ], + ); + } +} + +class _ProfileRow extends StatelessWidget { + const _ProfileRow({required this.icon, required this.title, required this.subtitle, required this.onTap}); + + final IconData icon; + final String title; + final String subtitle; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: WiseSpacing.x3), + child: AppCard( + onTap: onTap, + child: Row( + children: [ + Icon(icon, color: WiseColors.primary), + const SizedBox(width: WiseSpacing.x3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleMedium), + Text(subtitle, style: Theme.of(context).textTheme.bodySmall), + ], + ), + ), + const Icon(Icons.chevron_right, color: WiseColors.textTertiary), + ], + ), + ), + ); + } +} diff --git a/lib/features/reports/reports_page.dart b/lib/features/reports/reports_page.dart new file mode 100644 index 0000000..c8e0b89 --- /dev/null +++ b/lib/features/reports/reports_page.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; + +import '../../data/api/report_data_source.dart'; +import '../../data/models/models.dart'; +import '../../routing/app_routes.dart'; +import '../../theme/wise_tokens.dart'; +import '../../widgets/app_buttons.dart'; +import '../../widgets/badges.dart'; +import '../../widgets/mini_player.dart'; +import '../../widgets/states.dart'; +import '../shared/report_card_widget.dart'; + +class ReportsPage extends StatefulWidget { + const ReportsPage({ + required this.dataSource, + required this.onPlay, + this.player = const PlayerStateModel(), + this.onStartModuleAudio, + this.onToggleAudio, + this.onSeekAudio, + this.onSpeed, + super.key, + }); + + final ReportDataSource dataSource; + final void Function(AudioItem item) onPlay; + final PlayerStateModel player; + final void Function(String audioId, String reportId, String title, int durationSec)? onStartModuleAudio; + final VoidCallback? onToggleAudio; + final void Function(int delta)? onSeekAudio; + final VoidCallback? onSpeed; + + @override + State createState() => _ReportsPageState(); +} + +class _ReportsPageState extends State { + late Future> future = widget.dataSource.reports(); + String query = ''; + String topic = ''; + bool hasAudio = false; + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: future, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) return const LoadingState(label: '正在搜索研报'); + if (snapshot.hasError) return ErrorState(message: snapshot.error.toString(), onRetry: () => setState(() => future = widget.dataSource.reports())); + final items = applyFilters(snapshot.data ?? const []); + return ListView( + padding: const EdgeInsets.all(WiseSpacing.x4), + children: [ + TextField( + decoration: InputDecoration( + hintText: '搜索标题、机构或主题', + prefixIcon: const Icon(Icons.search), + suffixIcon: query.isEmpty ? null : IconButton(onPressed: () => setState(() => query = ''), icon: const Icon(Icons.close)), + filled: true, + fillColor: WiseColors.surface, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(WiseRadius.pill), borderSide: BorderSide.none), + ), + onChanged: (value) => setState(() => query = value.trim()), + ), + const SizedBox(height: WiseSpacing.x3), + Row( + children: [ + AppButton(label: '筛选', icon: Icons.tune, kind: AppButtonKind.ghost, onPressed: openFilterSheet), + const SizedBox(width: WiseSpacing.x2), + AppChip(label: '有音频', selected: hasAudio, onTap: () => setState(() => hasAudio = !hasAudio)), + ], + ), + const SizedBox(height: WiseSpacing.x3), + Text('共 ${items.length} 篇研报解读${query.isNotEmpty || topic.isNotEmpty || hasAudio ? '(已筛选)' : ''}', style: Theme.of(context).textTheme.bodySmall), + const SizedBox(height: WiseSpacing.x3), + if (items.isEmpty) + EmptyState( + title: query.isNotEmpty ? '未找到相关研报' : '当前筛选下暂无研报', + message: query.isNotEmpty ? '换个关键词试试' : '调整筛选条件后再试', + actionLabel: '清除筛选', + onAction: () => setState(() { + query = ''; + topic = ''; + hasAudio = false; + }), + ) + else + for (final report in items) ...[ + ReportCardWidget( + report: report, + onTap: () => openReportDetail( + context, + widget.dataSource, + report, + player: widget.player, + onStartAudio: widget.onStartModuleAudio, + onToggleAudio: widget.onToggleAudio, + onSeekAudio: widget.onSeekAudio, + onSpeed: widget.onSpeed, + ), + onPlayTap: () => widget.onPlay(AudioItem(audioId: 'local_${report.id}', reportId: report.id, titleCn: report.titleCn, reportTitleCn: report.titleCn, durationSec: 180, institution: report.institution)), + ), + const SizedBox(height: WiseSpacing.x3), + ], + ], + ); + }, + ); + } + + List applyFilters(List items) { + return items.where((item) { + final hay = '${item.titleCn} ${item.institution.nameCn} ${item.topics.join(' ')}'.toLowerCase(); + if (query.isNotEmpty && !hay.contains(query.toLowerCase())) return false; + if (topic.isNotEmpty && !item.topics.contains(topic)) return false; + if (hasAudio && !item.hasAudio) return false; + return true; + }).toList(); + } + + void openFilterSheet() { + widget.dataSource.reports().then((items) { + if (!mounted) return; + final topics = {for (final item in items) ...item.topics}.toList(); + showModalBottomSheet( + context: context, + showDragHandle: true, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(WiseRadius.lg))), + builder: (context) => Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 28), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('筛选研报', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: WiseSpacing.x3), + Wrap( + spacing: WiseSpacing.x2, + runSpacing: WiseSpacing.x2, + children: [ + AppChip(label: '全部主题', selected: topic.isEmpty, onTap: () => selectTopic('')), + for (final t in topics) AppChip(label: t, selected: topic == t, onTap: () => selectTopic(t)), + ], + ), + const SizedBox(height: WiseSpacing.x4), + AppButton(label: '完成', expand: true, onPressed: () => Navigator.pop(context)), + ], + ), + ), + ); + }); + } + + void selectTopic(String value) { + setState(() => topic = value); + } +} diff --git a/lib/features/shared/report_card_widget.dart b/lib/features/shared/report_card_widget.dart new file mode 100644 index 0000000..e1f7f18 --- /dev/null +++ b/lib/features/shared/report_card_widget.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; + +import '../../data/models/models.dart'; +import '../../theme/wise_tokens.dart'; +import '../../widgets/app_card.dart'; +import '../../widgets/badges.dart'; + +class ReportCardWidget extends StatelessWidget { + const ReportCardWidget({ + required this.report, + required this.onTap, + this.hero = false, + this.onInstitutionTap, + this.onPlayTap, + super.key, + }); + + final ReportCardModel report; + final VoidCallback onTap; + final bool hero; + final VoidCallback? onInstitutionTap; + final VoidCallback? onPlayTap; + + @override + Widget build(BuildContext context) { + final child = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: WiseSpacing.x2, + runSpacing: WiseSpacing.x2, + children: [ + AppBadge(text: report.interpretationLabel, kind: BadgeKind.brand), + if (report.hasAudio) + const AppBadge(text: '音频', icon: Icons.graphic_eq, kind: BadgeKind.audio), + if (report.sourceTier.isNotEmpty) + AppBadge(text: report.sourceTier, icon: Icons.verified_outlined, kind: BadgeKind.tier), + for (final topic in report.topics.take(3)) AppBadge(text: topic), + ], + ), + const SizedBox(height: WiseSpacing.x3), + Text( + report.titleCn, + maxLines: hero ? 3 : 2, + overflow: TextOverflow.ellipsis, + style: hero + ? Theme.of(context).textTheme.titleLarge + : Theme.of(context).textTheme.titleMedium, + ), + if (report.oneLiner.isNotEmpty) ...[ + const SizedBox(height: WiseSpacing.x2), + Text( + report.oneLiner, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: WiseSpacing.x3), + Row( + children: [ + Expanded( + child: InkWell( + onTap: onInstitutionTap, + child: Text( + '${report.institution.nameCn}${report.releasedAt == null ? '' : ' · ${formatDate(report.releasedAt)}'}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ), + if (report.hasAudio) + TextButton.icon( + onPressed: onPlayTap, + icon: const Icon(Icons.play_circle_outline, size: 18), + label: const Text('听研报'), + ), + ], + ), + ], + ); + return hero + ? HeroReportCard(onTap: onTap, child: child) + : AppCard(onTap: onTap, child: child); + } +} diff --git a/lib/features/shell_page.dart b/lib/features/shell_page.dart new file mode 100644 index 0000000..30886b5 --- /dev/null +++ b/lib/features/shell_page.dart @@ -0,0 +1,152 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../data/api/report_data_source.dart'; +import '../data/models/models.dart'; +import '../theme/wise_tokens.dart'; +import '../widgets/mini_player.dart'; +import 'feed/feed_page.dart'; +import 'institutions/institutions_page.dart'; +import 'listen/listen_page.dart'; +import 'profile/profile_page.dart'; +import 'reports/reports_page.dart'; + +class ShellPage extends StatefulWidget { + const ShellPage({required this.dataSource, super.key}); + + final ReportDataSource dataSource; + + @override + State createState() => _ShellPageState(); +} + +class _ShellPageState extends State { + int index = 0; + PlayerStateModel player = const PlayerStateModel(); + Timer? timer; + + @override + void dispose() { + timer?.cancel(); + super.dispose(); + } + + void startAudio(AudioItem item) { + timer?.cancel(); + setState(() { + player = PlayerStateModel( + audioId: item.audioId, + reportId: item.reportId, + title: item.titleCn, + durationSec: item.durationSec, + playing: true, + speed: player.speed, + ); + }); + timer = Timer.periodic(const Duration(seconds: 1), (_) => tick()); + } + + void startModuleAudio(String audioId, String reportId, String title, int durationSec) { + startAudio( + AudioItem( + audioId: audioId, + reportId: reportId, + titleCn: title, + reportTitleCn: title, + durationSec: durationSec, + institution: const Institution(id: '', nameCn: ''), + ), + ); + } + + void tick() { + if (!player.playing) return; + final next = player.positionSec + player.speed.round().clamp(1, 2); + setState(() { + player = player.copyWith( + positionSec: next >= player.durationSec ? player.durationSec : next, + playing: next < player.durationSec, + ); + }); + } + + void toggleAudio() { + if (!player.hasAudio) return; + setState(() => player = player.copyWith(playing: !player.playing)); + } + + void seekAudio(int delta) { + if (!player.hasAudio) return; + setState(() { + player = player.copyWith( + positionSec: (player.positionSec + delta).clamp(0, player.durationSec), + ); + }); + } + + void cycleSpeed() { + const speeds = [1.0, 1.25, 1.5, 2.0]; + final current = speeds.indexOf(player.speed); + setState(() => player = player.copyWith(speed: speeds[(current + 1) % speeds.length])); + } + + @override + Widget build(BuildContext context) { + final pages = [ + FeedPage( + dataSource: widget.dataSource, + onPlay: startAudio, + player: player, + onStartModuleAudio: startModuleAudio, + onToggleAudio: toggleAudio, + onSeekAudio: seekAudio, + onSpeed: cycleSpeed, + ), + ReportsPage( + dataSource: widget.dataSource, + onPlay: startAudio, + player: player, + onStartModuleAudio: startModuleAudio, + onToggleAudio: toggleAudio, + onSeekAudio: seekAudio, + onSpeed: cycleSpeed, + ), + InstitutionsPage(dataSource: widget.dataSource), + ListenPage(dataSource: widget.dataSource, onPlay: startAudio), + ProfilePage(dataSource: widget.dataSource), + ]; + return Scaffold( + appBar: AppBar( + title: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('研听'), + Text( + '全球机构研报中文解读', + style: TextStyle(fontSize: 12, color: WiseColors.textSecondary, fontWeight: FontWeight.w500), + ), + ], + ), + ), + body: pages[index], + bottomNavigationBar: Column( + mainAxisSize: MainAxisSize.min, + children: [ + MiniPlayer(player: player, onToggle: toggleAudio), + NavigationBar( + selectedIndex: index, + onDestinationSelected: (value) => setState(() => index = value), + destinations: const [ + NavigationDestination(icon: Icon(Icons.auto_awesome_outlined), selectedIcon: Icon(Icons.auto_awesome), label: '推荐'), + NavigationDestination(icon: Icon(Icons.article_outlined), selectedIcon: Icon(Icons.article), label: '研报'), + NavigationDestination(icon: Icon(Icons.account_balance_outlined), selectedIcon: Icon(Icons.account_balance), label: '机构'), + NavigationDestination(icon: Icon(Icons.headphones_outlined), selectedIcon: Icon(Icons.headphones), label: '听单'), + NavigationDestination(icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: '我的'), + ], + ), + ], + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..e44cf7d --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +import 'app.dart'; +import 'data/api/report_data_source.dart'; + +export 'app.dart'; +export 'data/api/report_data_source.dart'; +export 'data/models/models.dart'; + +void main() { + runApp(MyApp(dataSource: RnbApiDataSource())); +} diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart new file mode 100644 index 0000000..b597ea1 --- /dev/null +++ b/lib/routing/app_routes.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +import '../data/api/report_data_source.dart'; +import '../data/models/models.dart'; +import '../features/detail/report_detail_page.dart'; +import '../features/institutions/institution_detail_page.dart'; +import '../widgets/mini_player.dart'; + +void openReportDetail( + BuildContext context, + ReportDataSource dataSource, + ReportCardModel report, { + PlayerStateModel player = const PlayerStateModel(), + void Function(String audioId, String reportId, String title, int durationSec)? onStartAudio, + VoidCallback? onToggleAudio, + void Function(int delta)? onSeekAudio, + VoidCallback? onSpeed, +}) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ReportDetailPage( + reportId: report.id, + dataSource: dataSource, + player: player, + onStartAudio: onStartAudio, + onToggleAudio: onToggleAudio, + onSeekAudio: onSeekAudio, + onSpeed: onSpeed, + ), + ), + ); +} + +void openInstitutionDetail( + BuildContext context, + ReportDataSource dataSource, + String institutionId, +) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => InstitutionDetailPage( + institutionId: institutionId, + dataSource: dataSource, + ), + ), + ); +} diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart new file mode 100644 index 0000000..e5b1025 --- /dev/null +++ b/lib/theme/app_theme.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; + +import 'wise_tokens.dart'; + +ThemeData buildAppTheme() { + final scheme = ColorScheme.fromSeed( + seedColor: WiseColors.primary, + primary: WiseColors.primary, + secondary: WiseColors.secondary, + tertiary: WiseColors.accent, + surface: WiseColors.surface, + ); + return ThemeData( + useMaterial3: true, + colorScheme: scheme, + fontFamily: 'Inter', + scaffoldBackgroundColor: WiseColors.canvas, + appBarTheme: const AppBarTheme( + backgroundColor: WiseColors.canvas, + foregroundColor: WiseColors.primary, + elevation: 0, + centerTitle: false, + titleTextStyle: TextStyle( + color: WiseColors.primary, + fontSize: 22, + fontWeight: FontWeight.w800, + ), + ), + cardTheme: const CardThemeData( + color: WiseColors.surface, + elevation: 0, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(WiseRadius.md)), + ), + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: WiseColors.surface, + indicatorColor: WiseColors.secondary200, + labelTextStyle: WidgetStateProperty.resolveWith( + (states) => TextStyle( + color: states.contains(WidgetState.selected) + ? WiseColors.primary + : WiseColors.textTertiary, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + iconTheme: WidgetStateProperty.resolveWith( + (states) => IconThemeData( + color: states.contains(WidgetState.selected) + ? WiseColors.primary + : WiseColors.textTertiary, + ), + ), + ), + textTheme: const TextTheme( + headlineSmall: TextStyle( + color: WiseColors.ink, + fontSize: 26, + height: 1.18, + fontWeight: FontWeight.w800, + ), + titleLarge: TextStyle( + color: WiseColors.ink, + fontSize: 21, + height: 1.22, + fontWeight: FontWeight.w800, + ), + titleMedium: TextStyle( + color: WiseColors.ink, + fontSize: 17, + height: 1.25, + fontWeight: FontWeight.w800, + ), + bodyLarge: TextStyle( + color: WiseColors.ink, + fontSize: 16, + height: 1.55, + ), + bodyMedium: TextStyle( + color: WiseColors.ink700, + fontSize: 14, + height: 1.5, + ), + bodySmall: TextStyle( + color: WiseColors.textSecondary, + fontSize: 12, + height: 1.45, + ), + labelSmall: TextStyle( + color: WiseColors.textSecondary, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ); +} diff --git a/lib/theme/wise_tokens.dart b/lib/theme/wise_tokens.dart new file mode 100644 index 0000000..bfe3402 --- /dev/null +++ b/lib/theme/wise_tokens.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; + +final class WiseColors { + static const primary = Color(0xFF163300); + static const primarySoft = Color(0xFF1F4708); + static const secondary = Color(0xFF9FE870); + static const secondary200 = Color(0xFFE2F6D5); + static const accent = Color(0xFF00A2DD); + static const canvas = Color(0xFFF4F6F3); + static const ink = Color(0xFF0E0F0C); + static const ink700 = Color(0xFF454745); + static const textSecondary = Color(0xFF5D7079); + static const textTertiary = Color(0xFF768E9C); + static const surface = Colors.white; + static const border = Color(0x1A000000); + static const positive = Color(0xFF008026); + static const warning = Color(0xFF9A6500); + static const negative = Color(0xFFCF2929); +} + +final class WiseSpacing { + static const x1 = 4.0; + static const x2 = 8.0; + static const x3 = 12.0; + static const x4 = 16.0; + static const x5 = 20.0; + static const x6 = 24.0; + static const x8 = 32.0; + static const x10 = 40.0; +} + +final class WiseRadius { + static const sm = 10.0; + static const md = 16.0; + static const lg = 24.0; + static const pill = 999.0; +} + +final class WiseMotion { + static const short = Duration(milliseconds: 200); + static const base = Duration(milliseconds: 350); + static const curve = Cubic(0.8, 0.05, 0.2, 0.95); +} + +final class WiseShadows { + static const card = [ + BoxShadow( + color: Color(0x14000000), + blurRadius: 20, + offset: Offset(0, 6), + ), + ]; + static const elevated = [ + BoxShadow( + color: Color(0x24000000), + blurRadius: 32, + offset: Offset(0, 10), + ), + ]; +} + +const wiseFontStack = [ + 'Inter', + '-apple-system', + 'BlinkMacSystemFont', + 'PingFang SC', + 'Microsoft YaHei', + 'Helvetica Neue', + 'Arial', + 'sans-serif', +]; diff --git a/lib/widgets/app_buttons.dart b/lib/widgets/app_buttons.dart new file mode 100644 index 0000000..dbc8972 --- /dev/null +++ b/lib/widgets/app_buttons.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +import '../theme/wise_tokens.dart'; + +class AppButton extends StatelessWidget { + const AppButton({ + required this.label, + required this.onPressed, + this.icon, + this.kind = AppButtonKind.primary, + this.expand = false, + super.key, + }); + + final String label; + final VoidCallback? onPressed; + final IconData? icon; + final AppButtonKind kind; + final bool expand; + + @override + Widget build(BuildContext context) { + final colors = switch (kind) { + AppButtonKind.primary => (WiseColors.secondary, WiseColors.primary), + AppButtonKind.dark => (WiseColors.primary, Colors.white), + AppButtonKind.accent => (WiseColors.accent, Colors.white), + AppButtonKind.ghost => (WiseColors.surface, WiseColors.primary), + }; + final child = FilledButton.icon( + onPressed: onPressed, + icon: icon == null ? const SizedBox.shrink() : Icon(icon, size: 18), + label: Text(label), + style: FilledButton.styleFrom( + backgroundColor: colors.$1, + foregroundColor: colors.$2, + disabledBackgroundColor: WiseColors.border, + disabledForegroundColor: WiseColors.textTertiary, + minimumSize: Size(expand ? double.infinity : 0, 44), + shape: const StadiumBorder(), + ), + ); + return expand ? SizedBox(width: double.infinity, child: child) : child; + } +} + +enum AppButtonKind { primary, dark, accent, ghost } diff --git a/lib/widgets/app_card.dart b/lib/widgets/app_card.dart new file mode 100644 index 0000000..62a105e --- /dev/null +++ b/lib/widgets/app_card.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +import '../theme/wise_tokens.dart'; + +class AppCard extends StatelessWidget { + const AppCard({ + required this.child, + this.onTap, + this.padding = const EdgeInsets.all(WiseSpacing.x4), + this.color = WiseColors.surface, + super.key, + }); + + final Widget child; + final VoidCallback? onTap; + final EdgeInsetsGeometry padding; + final Color color; + + @override + Widget build(BuildContext context) { + final content = DecoratedBox( + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(WiseRadius.md), + boxShadow: WiseShadows.card, + ), + child: Padding(padding: padding, child: child), + ); + if (onTap == null) return content; + return InkWell( + borderRadius: BorderRadius.circular(WiseRadius.md), + onTap: onTap, + child: content, + ); + } +} + +class HeroReportCard extends StatelessWidget { + const HeroReportCard({required this.child, this.onTap, super.key}); + + final Widget child; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return AppCard( + onTap: onTap, + color: WiseColors.secondary200, + padding: const EdgeInsets.all(WiseSpacing.x5), + child: child, + ); + } +} diff --git a/lib/widgets/badges.dart b/lib/widgets/badges.dart new file mode 100644 index 0000000..964818e --- /dev/null +++ b/lib/widgets/badges.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; + +import '../theme/wise_tokens.dart'; + +class AppBadge extends StatelessWidget { + const AppBadge({ + required this.text, + this.icon, + this.kind = BadgeKind.neutral, + super.key, + }); + + final String text; + final IconData? icon; + final BadgeKind kind; + + @override + Widget build(BuildContext context) { + final colors = switch (kind) { + BadgeKind.brand => (WiseColors.secondary200, WiseColors.primarySoft), + BadgeKind.audio => (const Color(0x1F00A2DD), WiseColors.accent), + BadgeKind.tier => (const Color(0x1A008026), WiseColors.positive), + BadgeKind.warning => (const Color(0x209A6500), WiseColors.warning), + BadgeKind.neutral => (const Color(0x1286A7BD), WiseColors.textSecondary), + }; + return DecoratedBox( + decoration: BoxDecoration( + color: colors.$1, + borderRadius: BorderRadius.circular(WiseRadius.pill), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 14, color: colors.$2), + const SizedBox(width: 4), + ], + Text( + text, + style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colors.$2), + ), + ], + ), + ), + ); + } +} + +enum BadgeKind { brand, audio, tier, warning, neutral } + +class AppChip extends StatelessWidget { + const AppChip({ + required this.label, + this.selected = false, + this.onTap, + super.key, + }); + + final String label; + final bool selected; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return ActionChip( + onPressed: onTap, + label: Text(label), + labelStyle: TextStyle( + color: selected ? Colors.white : WiseColors.textSecondary, + fontWeight: FontWeight.w700, + ), + backgroundColor: selected ? WiseColors.primary : WiseColors.surface, + side: const BorderSide(color: WiseColors.border), + shape: const StadiumBorder(), + ); + } +} diff --git a/lib/widgets/mini_player.dart b/lib/widgets/mini_player.dart new file mode 100644 index 0000000..1f4ae54 --- /dev/null +++ b/lib/widgets/mini_player.dart @@ -0,0 +1,188 @@ +import 'package:flutter/material.dart'; + +import '../data/models/models.dart'; +import '../theme/wise_tokens.dart'; +import 'app_card.dart'; + +class PlayerStateModel { + const PlayerStateModel({ + this.audioId = '', + this.reportId = '', + this.title = '', + this.durationSec = 0, + this.positionSec = 0, + this.playing = false, + this.speed = 1.0, + }); + + final String audioId; + final String reportId; + final String title; + final int durationSec; + final int positionSec; + final bool playing; + final double speed; + + bool get hasAudio => audioId.isNotEmpty; + + PlayerStateModel copyWith({ + String? audioId, + String? reportId, + String? title, + int? durationSec, + int? positionSec, + bool? playing, + double? speed, + }) { + return PlayerStateModel( + audioId: audioId ?? this.audioId, + reportId: reportId ?? this.reportId, + title: title ?? this.title, + durationSec: durationSec ?? this.durationSec, + positionSec: positionSec ?? this.positionSec, + playing: playing ?? this.playing, + speed: speed ?? this.speed, + ); + } +} + +class MiniPlayer extends StatelessWidget { + const MiniPlayer({ + required this.player, + required this.onToggle, + super.key, + }); + + final PlayerStateModel player; + final VoidCallback onToggle; + + @override + Widget build(BuildContext context) { + if (!player.hasAudio) return const SizedBox.shrink(); + final ratio = player.durationSec == 0 ? 0.0 : player.positionSec / player.durationSec; + return Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: AppCard( + padding: const EdgeInsets.all(12), + color: WiseColors.primary, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + IconButton.filled( + onPressed: onToggle, + icon: Icon(player.playing ? Icons.pause : Icons.play_arrow), + style: IconButton.styleFrom( + backgroundColor: WiseColors.secondary, + foregroundColor: WiseColors.primary, + ), + ), + const SizedBox(width: WiseSpacing.x2), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + player.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w800), + ), + Text( + '${formatDuration(player.positionSec)} / ${formatDuration(player.durationSec)} · ${player.speed.toStringAsFixed(1)}x', + style: const TextStyle(color: Color(0xCCFFFFFF), fontSize: 12), + ), + ], + ), + ), + ], + ), + const SizedBox(height: WiseSpacing.x2), + LinearProgressIndicator( + value: ratio.clamp(0, 1), + minHeight: 4, + backgroundColor: const Color(0x33FFFFFF), + color: WiseColors.secondary, + ), + ], + ), + ), + ); + } +} + +class PlayerCard extends StatelessWidget { + const PlayerCard({ + required this.title, + required this.durationSec, + required this.player, + required this.onStart, + required this.onToggle, + required this.onSeek, + required this.onSpeed, + super.key, + }); + + final String title; + final int durationSec; + final PlayerStateModel player; + final VoidCallback onStart; + final VoidCallback onToggle; + final void Function(int delta) onSeek; + final VoidCallback onSpeed; + + @override + Widget build(BuildContext context) { + final active = player.hasAudio && player.title == title; + final position = active ? player.positionSec : 0; + final ratio = durationSec == 0 ? 0.0 : position / durationSec; + return AppCard( + color: WiseColors.secondary200, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('音频解读', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: WiseSpacing.x2), + Text(title, style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: WiseSpacing.x3), + LinearProgressIndicator( + value: ratio.clamp(0, 1), + minHeight: 6, + backgroundColor: Colors.white, + color: WiseColors.accent, + ), + const SizedBox(height: WiseSpacing.x2), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(formatDuration(position), style: Theme.of(context).textTheme.bodySmall), + Text(formatDuration(durationSec), style: Theme.of(context).textTheme.bodySmall), + ], + ), + const SizedBox(height: WiseSpacing.x3), + Row( + children: [ + IconButton.outlined(onPressed: () => onSeek(-15), icon: const Icon(Icons.replay_10)), + IconButton.filled( + onPressed: active ? onToggle : onStart, + icon: Icon(active && player.playing ? Icons.pause : Icons.play_arrow), + style: IconButton.styleFrom( + backgroundColor: WiseColors.primary, + foregroundColor: Colors.white, + ), + ), + IconButton.outlined(onPressed: () => onSeek(15), icon: const Icon(Icons.forward_10)), + const Spacer(), + TextButton(onPressed: onSpeed, child: Text('${player.speed.toStringAsFixed(1)}x')), + ], + ), + Text( + '真实音频流待 /audio/{id}/stream 接入,当前为本地进度占位。', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ); + } +} diff --git a/lib/widgets/sheets.dart b/lib/widgets/sheets.dart new file mode 100644 index 0000000..f698c18 --- /dev/null +++ b/lib/widgets/sheets.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; + +import '../theme/wise_tokens.dart'; +import 'app_buttons.dart'; +import 'states.dart'; + +Future showLoginSheet(BuildContext context, {String reason = '登录后保存当前动作'}) { + return showModalBottomSheet( + context: context, + showDragHandle: true, + backgroundColor: WiseColors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(WiseRadius.lg)), + ), + builder: (context) => Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 28), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('登录研听', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: WiseSpacing.x2), + Text(reason, style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: WiseSpacing.x4), + AppButton( + label: '使用手机号继续', + icon: Icons.phone_iphone, + expand: true, + onPressed: () { + Navigator.pop(context); + showAppToast(context, '登录接口待接入,已保留当前页面'); + }, + ), + const SizedBox(height: WiseSpacing.x2), + AppButton( + label: '微信 / Apple 登录占位', + icon: Icons.account_circle_outlined, + kind: AppButtonKind.ghost, + expand: true, + onPressed: () { + Navigator.pop(context); + showAppToast(context, '真实 auth 待后端接入'); + }, + ), + ], + ), + ), + ); +} + +Future showOutboundSheet(BuildContext context, {required String title}) { + return showModalBottomSheet( + context: context, + showDragHandle: true, + backgroundColor: WiseColors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(WiseRadius.lg)), + ), + builder: (context) => Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 28), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('即将打开外部服务', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: WiseSpacing.x2), + Text( + '$title\n外跳仅用于了解原文或相关服务,本内容不构成投资建议。', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: WiseSpacing.x4), + AppButton( + label: '确认并记录占位事件', + icon: Icons.open_in_new, + kind: AppButtonKind.accent, + expand: true, + onPressed: () { + Navigator.pop(context); + showAppToast(context, '外跳事件接口待接入'); + }, + ), + ], + ), + ), + ); +} diff --git a/lib/widgets/states.dart b/lib/widgets/states.dart new file mode 100644 index 0000000..bb5efdc --- /dev/null +++ b/lib/widgets/states.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; + +import '../theme/wise_tokens.dart'; +import 'app_buttons.dart'; +import 'app_card.dart'; + +class LoadingState extends StatelessWidget { + const LoadingState({this.label = '正在加载研报解读', super.key}); + + final String label; + + @override + Widget build(BuildContext context) { + return ListView.separated( + padding: const EdgeInsets.all(WiseSpacing.x4), + itemCount: 4, + separatorBuilder: (_, _) => const SizedBox(height: WiseSpacing.x3), + itemBuilder: (context, index) => const SkeletonCard(), + ); + } +} + +class SkeletonCard extends StatelessWidget { + const SkeletonCard({super.key}); + + @override + Widget build(BuildContext context) { + return AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + SkeletonLine(width: 96), + SizedBox(height: WiseSpacing.x3), + SkeletonLine(width: double.infinity, height: 18), + SizedBox(height: WiseSpacing.x2), + SkeletonLine(width: 240), + SizedBox(height: WiseSpacing.x3), + SkeletonLine(width: 160), + ], + ), + ); + } +} + +class SkeletonLine extends StatelessWidget { + const SkeletonLine({required this.width, this.height = 12, super.key}); + + final double width; + final double height; + + @override + Widget build(BuildContext context) { + return Container( + width: width, + height: height, + decoration: BoxDecoration( + color: WiseColors.border, + borderRadius: BorderRadius.circular(WiseRadius.pill), + ), + ); + } +} + +class EmptyState extends StatelessWidget { + const EmptyState({ + required this.title, + required this.message, + this.icon = Icons.search_off, + this.actionLabel, + this.onAction, + super.key, + }); + + final String title; + final String message; + final IconData icon; + final String? actionLabel; + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(WiseSpacing.x6), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 42, color: WiseColors.primary), + const SizedBox(height: WiseSpacing.x3), + Text(title, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: WiseSpacing.x2), + Text( + message, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + if (actionLabel != null) ...[ + const SizedBox(height: WiseSpacing.x4), + AppButton(label: actionLabel!, onPressed: onAction, kind: AppButtonKind.ghost), + ], + ], + ), + ), + ); + } +} + +class ErrorState extends StatelessWidget { + const ErrorState({required this.message, this.onRetry, super.key}); + + final String message; + final VoidCallback? onRetry; + + @override + Widget build(BuildContext context) { + return EmptyState( + icon: Icons.cloud_off_outlined, + title: '内容暂时加载失败', + message: message, + actionLabel: onRetry == null ? null : '重试', + onAction: onRetry, + ); + } +} + +void showAppToast(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + behavior: SnackBarBehavior.floating, + backgroundColor: WiseColors.primary, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(WiseRadius.md)), + ), + ); +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..b4b3ba2 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,245 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" +sdks: + dart: ">=3.12.1 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..b97add1 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,90 @@ +name: report_notebooklm_app +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.12.1 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + http: ^1.6.0 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..318bcc2 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,161 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:report_notebooklm_app/main.dart'; + +void main() { + testWidgets('renders shell tabs and report detail modules', (tester) async { + await tester.pumpWidget(MyApp(dataSource: FakeDataSource())); + await tester.pumpAndSettle(); + + expect(find.text('推荐'), findsWidgets); + expect(find.text('研报'), findsWidgets); + expect(find.text('机构'), findsWidgets); + expect(find.text('听单'), findsWidgets); + expect(find.text('我的'), findsWidgets); + expect(find.text('黄金月报:金价新高之后,谁在继续买?'), findsOneWidget); + + await tester.tap(find.text('黄金月报:金价新高之后,谁在继续买?')); + await tester.pumpAndSettle(); + + expect(find.text('研报详情'), findsOneWidget); + expect(find.text('报告要点'), findsWidgets); + expect(find.text('报告中的关键数据'), findsWidgets); + expect(find.text('查看详情'), findsWidgets); + + await tester.tap(find.text('报告摘要').last); + await tester.pumpAndSettle(); + + expect(find.text('报告摘要'), findsOneWidget); + expect(find.text('需求结构'), findsOneWidget); + }); +} + +class FakeDataSource implements ReportDataSource { + final institution = const Institution( + id: 'inst_ssga', + nameCn: '道富环球投资管理', + nameEn: 'State Street Global Advisors', + sourceTier: 'tier_2', + coveredTopics: ['贵金属'], + reportCount: 1, + ); + + late final report = ReportCardModel( + id: 'rep_ssga_gold', + titleCn: '黄金月报:金价新高之后,谁在继续买?', + oneLiner: '央行购金转向结构性,ETF 重新净流入。', + institution: institution, + topics: const ['贵金属', '跨资产'], + releasedAt: '2026-05-22T00:00:00', + hasAudio: true, + interpretationLabel: '研报解读', + sourceTier: 'authorized_partner', + cacheVersion: 'rep_ssga_gold:v1', + ); + + @override + Future> recommended() async => [report]; + + @override + Future> reports() async => [report]; + + @override + Future> institutions() async => [institution]; + + @override + Future institutionDetail(String institutionId) async { + return Institution( + id: institution.id, + nameCn: institution.nameCn, + nameEn: institution.nameEn, + sourceTier: institution.sourceTier, + coveredTopics: institution.coveredTopics, + reportCount: 1, + introCn: '道富环球投资管理的公开研究用于演示。', + credibilityNote: 'tier_2 来源。', + recentReports: [report], + ); + } + + @override + Future> listen() async => [ + AudioItem( + audioId: 'aud_ssga_gold', + titleCn: '金价新高之后,谁在继续买?', + reportTitleCn: report.titleCn, + durationSec: 180, + reportId: report.id, + institution: institution, + ), + ]; + + @override + Future reportDetail(String reportId) async => ReportDetail( + id: report.id, + titleCn: report.titleCn, + oneLiner: report.oneLiner, + institution: institution, + source: const { + 'source_tier': 'authorized_partner', + 'source_note': '原文来源于机构公开研究页。', + }, + topics: report.topics, + hasAudio: true, + releasedAt: report.releasedAt, + cacheVersion: report.cacheVersion, + modules: const [ + DisplayModule( + id: 'mod_ssga_gold_executive_overview', + type: 'executive_overview', + layer: 'p0', + renderMode: 'card_plus_page', + hasDetailPage: true, + sortOrder: 1, + titleCn: '报告摘要', + preview: {'preview_summary': '本期月报拆解黄金买盘。', 'section_count': 3}, + ), + DisplayModule( + id: 'mod_ssga_gold_core_insights', + type: 'core_insights', + layer: 'p0', + renderMode: 'inline', + hasDetailPage: true, + sortOrder: 2, + titleCn: '报告要点', + content: { + 'points': [ + {'kind': 'view', 'text': '央行购金从机会性买入转向结构性配置。'}, + ], + }, + ), + DisplayModule( + id: 'mod_ssga_gold_key_data', + type: 'key_data', + layer: 'p0', + renderMode: 'card_plus_page', + hasDetailPage: true, + sortOrder: 3, + titleCn: '报告中的关键数据', + preview: { + 'preview_headline': '10 个关键数据点', + 'highlights': ['ETF 连续净流入'], + }, + ), + ], + ); + + @override + Future moduleDetail(String reportId, String moduleId) async { + return ModuleDetail( + id: moduleId, + type: 'executive_overview', + titleCn: '报告摘要', + content: const { + 'sections': [ + {'heading': '需求结构', 'body': '央行与 ETF 买盘提供支撑。'}, + ], + }, + contentEtag: 'etag', + cacheVersion: 'rep_ssga_gold:v1', + ); + } +} diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..33ab85e --- /dev/null +++ b/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + 研听 + + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..b8728f1 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "研听", + "short_name": "研听", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "研听 - 全球机构研报中文解读 App", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +}