merge: 将 develop 合入 release/v1.0.0
# Conflicts: # lib/core/config/app_config.dart # lib/main.dart # test/config/server_endpoint_test.dart
No files matched your search
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@color/splash_background" />
|
||||||
|
</layer-list>
|
||||||
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@color/splash_background" />
|
||||||
|
</layer-list>
|
||||||
|
After Width: | Height: | Size: 69 B |
@@ -1,11 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Author: fruit.com.cn -->
|
|
||||||
|
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="@color/splash_background" />
|
|
||||||
<item>
|
<item>
|
||||||
<bitmap
|
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||||
android:gravity="center"
|
</item>
|
||||||
android:src="@drawable/splash_icon" />
|
<item>
|
||||||
|
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||||
</item>
|
</item>
|
||||||
</layer-list>
|
</layer-list>
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Author: fruit.com.cn -->
|
<!-- Author: fruit.com.cn -->
|
||||||
<!-- Android 12 already shows the icon in the system splash. This layer stays
|
|
||||||
the same color so the icon is not drawn a second time. -->
|
|
||||||
|
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="@color/splash_background" />
|
<item android:drawable="@color/splash_background" />
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 69 B |
@@ -1,11 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Author: fruit.com.cn -->
|
|
||||||
|
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="@color/splash_background" />
|
|
||||||
<item>
|
<item>
|
||||||
<bitmap
|
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||||
android:gravity="center"
|
</item>
|
||||||
android:src="@drawable/splash_icon" />
|
<item>
|
||||||
|
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||||
</item>
|
</item>
|
||||||
</layer-list>
|
</layer-list>
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@color/ic_launcher_background"/>
|
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||||
<foreground>
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
<inset
|
|
||||||
android:drawable="@drawable/ic_launcher_foreground"
|
|
||||||
android:inset="16%" />
|
|
||||||
</foreground>
|
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 772 B After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -6,6 +6,8 @@
|
|||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
|
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
|
||||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
||||||
<item name="android:windowSplashScreenIconBackgroundColor">@color/splash_background</item>
|
</style>
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">@color/splash_background</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Author: fruit.com.cn -->
|
<!-- Author: fruit.com.cn -->
|
||||||
|
|
||||||
<resources>
|
<resources>
|
||||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
<!-- Show a splash screen on the activity. Automatically removed when
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
the Flutter engine draws its first frame -->
|
the Flutter engine draws its first frame -->
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
<item name="android:forceDarkAllowed">false</item>
|
||||||
|
<item name="android:windowFullscreen">false</item>
|
||||||
|
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
</style>
|
</style>
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
This theme determines the color of the Android Window while your
|
This theme determines the color of the Android Window while your
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
|
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
|
||||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
||||||
<item name="android:windowSplashScreenIconBackgroundColor">@color/splash_background</item>
|
</style>
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">@color/splash_background</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -2,6 +2,5 @@
|
|||||||
<!-- Author: fruit.com.cn -->
|
<!-- Author: fruit.com.cn -->
|
||||||
|
|
||||||
<resources>
|
<resources>
|
||||||
<color name="splash_background">#FF100C0A</color>
|
<color name="splash_background">#1F1B16</color>
|
||||||
<color name="ic_launcher_background">#C4322A</color>
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Author: fruit.com.cn -->
|
<!-- Author: fruit.com.cn -->
|
||||||
|
|
||||||
<resources>
|
<resources>
|
||||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
<!-- Show a splash screen on the activity. Automatically removed when
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
the Flutter engine draws its first frame -->
|
the Flutter engine draws its first frame -->
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
<item name="android:forceDarkAllowed">false</item>
|
||||||
|
<item name="android:windowFullscreen">false</item>
|
||||||
|
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
</style>
|
</style>
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
This theme determines the color of the Android Window while your
|
This theme determines the color of the Android Window while your
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 866 B |
|
Before Width: | Height: | Size: 594 B After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 939 B After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 436 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 902 B After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 594 B After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 742 B After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 867 B After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "background.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 69 B |
@@ -1,23 +1,23 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"idiom" : "universal",
|
|
||||||
"filename" : "LaunchImage.png",
|
"filename" : "LaunchImage.png",
|
||||||
|
"idiom" : "universal",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idiom" : "universal",
|
|
||||||
"filename" : "LaunchImage@2x.png",
|
"filename" : "LaunchImage@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
"scale" : "2x"
|
"scale" : "2x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idiom" : "universal",
|
|
||||||
"filename" : "LaunchImage@3x.png",
|
"filename" : "LaunchImage@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
"scale" : "3x"
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
"version" : 1,
|
"author" : "xcode",
|
||||||
"author" : "xcode"
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 37 KiB |
@@ -1,5 +0,0 @@
|
|||||||
# Launch Screen Assets
|
|
||||||
|
|
||||||
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
|
|
||||||
|
|
||||||
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
|
|
||||||
@@ -9,16 +9,26 @@
|
|||||||
<scene sceneID="EHf-IW-A2E">
|
<scene sceneID="EHf-IW-A2E">
|
||||||
<objects>
|
<objects>
|
||||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||||
|
<layoutGuides>
|
||||||
|
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
|
||||||
|
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
|
||||||
|
</layoutGuides>
|
||||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/>
|
||||||
</imageView>
|
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"></imageView>
|
||||||
</subviews>
|
</subviews>
|
||||||
<color key="backgroundColor" red="0.06274509803921569" green="0.047058823529411764" blue="0.0392156862745098" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="cx-launch"/>
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
|
||||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="cy-launch"/>
|
<constraint firstItem="tWc-Dq-wcI" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="RPx-PI-7Xg"/>
|
||||||
|
<constraint firstItem="tWc-Dq-wcI" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="SdS-ul-q2q"/>
|
||||||
|
<constraint firstAttribute="trailing" secondItem="tWc-Dq-wcI" secondAttribute="trailing" id="Swv-Gf-Rwn"/>
|
||||||
|
<constraint firstAttribute="trailing" secondItem="YRO-k0-Ey4" secondAttribute="trailing" id="TQA-XW-tRk"/>
|
||||||
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="duK-uY-Gun"/>
|
||||||
|
<constraint firstItem="tWc-Dq-wcI" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="kV7-tw-vXt"/>
|
||||||
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="xPn-NY-SIU"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
</view>
|
</view>
|
||||||
</viewController>
|
</viewController>
|
||||||
@@ -28,6 +38,7 @@
|
|||||||
</scene>
|
</scene>
|
||||||
</scenes>
|
</scenes>
|
||||||
<resources>
|
<resources>
|
||||||
<image name="LaunchImage" width="84" height="84"/>
|
<image name="LaunchImage" width="1152" height="1152"/>
|
||||||
|
<image name="LaunchBackground" width="1" height="1"/>
|
||||||
</resources>
|
</resources>
|
||||||
</document>
|
</document>
|
||||||
@@ -28,8 +28,41 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<true/>
|
||||||
<key>NSAllowsLocalNetworking</key>
|
<key>NSAllowsLocalNetworking</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSExceptionDomains</key>
|
||||||
|
<dict>
|
||||||
|
<key>127.0.0.1</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSIncludesSubdomains</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>localhost</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSIncludesSubdomains</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>192.168.31.187</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSIncludesSubdomains</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>39.162.50.99</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSIncludesSubdomains</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
<string>开发阶段需要访问同一局域网内的短剧内容服务。</string>
|
<string>开发阶段需要访问同一局域网内的短剧内容服务。</string>
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'core/config/locale_controller.dart';
|
import 'core/config/app_locale.dart';
|
||||||
import 'core/data/remote/account_api.dart';
|
import 'core/data/remote/account_api.dart';
|
||||||
import 'core/data/remote/api_client.dart';
|
import 'core/data/remote/api_client.dart';
|
||||||
import 'core/data/remote/auth_api.dart';
|
import 'core/data/remote/auth_api.dart';
|
||||||
import 'core/data/remote/catalog_api.dart';
|
import 'core/data/remote/catalog_api.dart';
|
||||||
import 'core/data/remote/category_api.dart';
|
import 'core/data/remote/category_api.dart';
|
||||||
import 'core/data/remote/comment_api.dart';
|
import 'core/data/remote/comment_api.dart';
|
||||||
|
import 'core/data/remote/commerce_api.dart';
|
||||||
import 'core/data/remote/download_api.dart';
|
import 'core/data/remote/download_api.dart';
|
||||||
import 'core/data/remote/interaction_api.dart';
|
import 'core/data/remote/interaction_api.dart';
|
||||||
import 'core/data/remote/library_api.dart';
|
import 'core/data/remote/library_api.dart';
|
||||||
@@ -23,11 +24,14 @@ import 'core/data/repositories/catalog_repository.dart';
|
|||||||
import 'core/data/repositories/account_repository.dart';
|
import 'core/data/repositories/account_repository.dart';
|
||||||
import 'core/data/repositories/category_repository.dart';
|
import 'core/data/repositories/category_repository.dart';
|
||||||
import 'core/data/repositories/comment_repository.dart';
|
import 'core/data/repositories/comment_repository.dart';
|
||||||
|
import 'core/data/repositories/commerce_repository.dart';
|
||||||
import 'core/data/repositories/download_repository.dart';
|
import 'core/data/repositories/download_repository.dart';
|
||||||
import 'core/data/repositories/favorite_repository.dart';
|
import 'core/data/repositories/favorite_repository.dart';
|
||||||
import 'core/data/repositories/interaction_repository.dart';
|
import 'core/data/repositories/interaction_repository.dart';
|
||||||
import 'core/data/repositories/notice_repository.dart';
|
import 'core/data/repositories/notice_repository.dart';
|
||||||
import 'core/data/repositories/playback_repository.dart';
|
import 'core/data/repositories/playback_repository.dart';
|
||||||
|
import 'core/data/repositories/search_repository.dart';
|
||||||
|
import 'core/data/repositories/store_repository.dart';
|
||||||
import 'core/data/repositories/watch_history_repository.dart';
|
import 'core/data/repositories/watch_history_repository.dart';
|
||||||
import 'core/data/services/auth_controller.dart';
|
import 'core/data/services/auth_controller.dart';
|
||||||
import 'core/data/services/auth_session_store.dart';
|
import 'core/data/services/auth_session_store.dart';
|
||||||
@@ -36,14 +40,27 @@ import 'core/data/services/category_controller.dart';
|
|||||||
import 'core/data/services/download_controller.dart';
|
import 'core/data/services/download_controller.dart';
|
||||||
import 'core/data/services/favorite_controller.dart';
|
import 'core/data/services/favorite_controller.dart';
|
||||||
import 'core/data/services/interaction_controller.dart';
|
import 'core/data/services/interaction_controller.dart';
|
||||||
|
import 'core/data/services/locale_controller.dart';
|
||||||
import 'core/data/services/playback_session.dart';
|
import 'core/data/services/playback_session.dart';
|
||||||
import 'core/data/services/video_player_port.dart';
|
import 'core/data/services/video_player_port.dart';
|
||||||
|
import 'core/data/services/wallet_controller.dart';
|
||||||
|
import 'core/navigation/app_route_observer.dart';
|
||||||
import 'core/platform/haptics.dart';
|
import 'core/platform/haptics.dart';
|
||||||
import 'core/theme/app_theme.dart';
|
import 'core/theme/app_theme.dart';
|
||||||
import 'core/theme/app_colors.dart';
|
import 'core/theme/app_colors.dart';
|
||||||
import 'l10n/app_localizations.dart';
|
import 'l10n/app_localizations.dart';
|
||||||
import 'pages/home/home_view.dart';
|
import 'pages/home/home_view.dart';
|
||||||
|
|
||||||
|
/// 全局系统栏样式:浅暖导航栏、关闭 Android 对比遮罩。
|
||||||
|
final SystemUiOverlayStyle appSystemOverlayStyle = SystemUiOverlayStyle.dark
|
||||||
|
.copyWith(
|
||||||
|
statusBarColor: Colors.transparent,
|
||||||
|
systemNavigationBarColor: AppColors.ink,
|
||||||
|
systemNavigationBarDividerColor: Colors.transparent,
|
||||||
|
systemNavigationBarIconBrightness: Brightness.dark,
|
||||||
|
systemNavigationBarContrastEnforced: false,
|
||||||
|
);
|
||||||
|
|
||||||
class LumaDramaApp extends StatefulWidget {
|
class LumaDramaApp extends StatefulWidget {
|
||||||
const LumaDramaApp({
|
const LumaDramaApp({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -58,6 +75,9 @@ class LumaDramaApp extends StatefulWidget {
|
|||||||
this.interactionRepository,
|
this.interactionRepository,
|
||||||
this.noticeRepository,
|
this.noticeRepository,
|
||||||
this.accountRepository,
|
this.accountRepository,
|
||||||
|
this.commerceRepository,
|
||||||
|
this.storeRepository,
|
||||||
|
this.searchRepository,
|
||||||
this.haptics = const FlutterHaptics(),
|
this.haptics = const FlutterHaptics(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,6 +94,9 @@ class LumaDramaApp extends StatefulWidget {
|
|||||||
final InteractionRepository? interactionRepository;
|
final InteractionRepository? interactionRepository;
|
||||||
final NoticeRepository? noticeRepository;
|
final NoticeRepository? noticeRepository;
|
||||||
final AccountRepository? accountRepository;
|
final AccountRepository? accountRepository;
|
||||||
|
final CommerceRepository? commerceRepository;
|
||||||
|
final StoreRepository? storeRepository;
|
||||||
|
final SearchRepository? searchRepository;
|
||||||
final Haptics haptics;
|
final Haptics haptics;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -134,6 +157,20 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
|||||||
_downloadRepository,
|
_downloadRepository,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
late final CommerceRepository _commerceRepository =
|
||||||
|
widget.commerceRepository ??
|
||||||
|
HttpCommerceRepository(CommerceApi(_apiClient));
|
||||||
|
|
||||||
|
late final WalletController _walletController = WalletController(
|
||||||
|
_commerceRepository,
|
||||||
|
);
|
||||||
|
|
||||||
|
late final SearchRepository _searchRepository =
|
||||||
|
widget.searchRepository ?? HttpSearchRepository(CatalogApi(_apiClient));
|
||||||
|
|
||||||
|
late final StoreRepository _storeRepository =
|
||||||
|
widget.storeRepository ?? HttpStoreRepository(CommerceApi(_apiClient));
|
||||||
|
|
||||||
late final CommentRepository _commentRepository =
|
late final CommentRepository _commentRepository =
|
||||||
widget.commentRepository ?? HttpCommentRepository(CommentApi(_apiClient));
|
widget.commentRepository ?? HttpCommentRepository(CommentApi(_apiClient));
|
||||||
|
|
||||||
@@ -150,14 +187,19 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
|||||||
late final InteractionController _interactionController =
|
late final InteractionController _interactionController =
|
||||||
InteractionController(_interactionRepository);
|
InteractionController(_interactionRepository);
|
||||||
|
|
||||||
|
late final LocaleController _localeController = LocaleController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// 恢复本地令牌与设备号;未登录也能正常浏览,不阻塞首屏
|
// 恢复本地令牌与设备号;未登录也能正常浏览,不阻塞首屏
|
||||||
_auth.load();
|
_auth.load();
|
||||||
|
unawaited(_localeController.load());
|
||||||
// 登录/登出时同步收藏状态:收藏属于账号,不能跨账号串联
|
// 登录/登出时同步收藏状态:收藏属于账号,不能跨账号串联
|
||||||
_auth.addListener(_onAuthChanged);
|
_auth.addListener(_onAuthChanged);
|
||||||
if (_auth.isSignedIn) unawaited(_favoriteController.load());
|
if (_auth.isSignedIn) unawaited(_favoriteController.load());
|
||||||
|
if (_auth.isSignedIn) unawaited(_walletController.load());
|
||||||
|
unawaited(_downloadController.restore());
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _wasSignedIn = false;
|
bool _wasSignedIn = false;
|
||||||
@@ -168,9 +210,11 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
|||||||
_interactionController.clear();
|
_interactionController.clear();
|
||||||
if (_auth.isSignedIn) {
|
if (_auth.isSignedIn) {
|
||||||
unawaited(_favoriteController.load());
|
unawaited(_favoriteController.load());
|
||||||
|
unawaited(_walletController.load());
|
||||||
} else {
|
} else {
|
||||||
_favoriteController.clear();
|
_favoriteController.clear();
|
||||||
_downloadController.reset();
|
_downloadController.reset();
|
||||||
|
_walletController.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +223,9 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
|||||||
_auth.removeListener(_onAuthChanged);
|
_auth.removeListener(_onAuthChanged);
|
||||||
_favoriteController.dispose();
|
_favoriteController.dispose();
|
||||||
_downloadController.dispose();
|
_downloadController.dispose();
|
||||||
|
_walletController.dispose();
|
||||||
_interactionController.dispose();
|
_interactionController.dispose();
|
||||||
|
_localeController.dispose();
|
||||||
_apiClient.close();
|
_apiClient.close();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -198,6 +244,9 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
|||||||
Provider<InteractionRepository>.value(value: _interactionRepository),
|
Provider<InteractionRepository>.value(value: _interactionRepository),
|
||||||
Provider<NoticeRepository>.value(value: _noticeRepository),
|
Provider<NoticeRepository>.value(value: _noticeRepository),
|
||||||
Provider<AccountRepository>.value(value: _accountRepository),
|
Provider<AccountRepository>.value(value: _accountRepository),
|
||||||
|
Provider<CommerceRepository>.value(value: _commerceRepository),
|
||||||
|
Provider<StoreRepository>.value(value: _storeRepository),
|
||||||
|
Provider<SearchRepository>.value(value: _searchRepository),
|
||||||
Provider<Haptics>.value(value: widget.haptics),
|
Provider<Haptics>.value(value: widget.haptics),
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (_) => CatalogController(_catalogRepository)..load(),
|
create: (_) => CatalogController(_catalogRepository)..load(),
|
||||||
@@ -205,30 +254,28 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
|||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (_) => CategoryController(_categoryRepository)..load(),
|
create: (_) => CategoryController(_categoryRepository)..load(),
|
||||||
),
|
),
|
||||||
ChangeNotifierProvider(create: (_) => LocaleController()..load()),
|
|
||||||
ChangeNotifierProvider.value(value: _favoriteController),
|
ChangeNotifierProvider.value(value: _favoriteController),
|
||||||
ChangeNotifierProvider.value(value: _downloadController),
|
ChangeNotifierProvider.value(value: _downloadController),
|
||||||
|
ChangeNotifierProvider.value(value: _walletController),
|
||||||
ChangeNotifierProvider.value(value: _interactionController),
|
ChangeNotifierProvider.value(value: _interactionController),
|
||||||
|
ChangeNotifierProvider.value(value: _localeController),
|
||||||
ChangeNotifierProvider.value(value: _auth),
|
ChangeNotifierProvider.value(value: _auth),
|
||||||
],
|
],
|
||||||
child: Consumer<LocaleController>(
|
child: Consumer<LocaleController>(
|
||||||
builder: (context, locale, _) => MaterialApp(
|
builder: (context, localeController, _) => MaterialApp(
|
||||||
|
locale: localeController.locale,
|
||||||
onGenerateTitle: (context) => AppLocalizations.of(context)!.appName,
|
onGenerateTitle: (context) => AppLocalizations.of(context)!.appName,
|
||||||
locale: locale.locale,
|
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
navigatorObservers: [appRouteObserver],
|
||||||
localeResolutionCallback: (deviceLocale, _) =>
|
localeResolutionCallback: (deviceLocale, _) =>
|
||||||
resolveAppLocale(locale.locale, deviceLocale),
|
resolveAppLocale(deviceLocale),
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
themeMode: ThemeMode.dark,
|
themeMode: ThemeMode.light,
|
||||||
theme: AppTheme.light(),
|
theme: AppTheme.light(),
|
||||||
darkTheme: AppTheme.dark(),
|
darkTheme: AppTheme.light(),
|
||||||
builder: (context, child) => AnnotatedRegion<SystemUiOverlayStyle>(
|
builder: (context, child) => AnnotatedRegion<SystemUiOverlayStyle>(
|
||||||
value: SystemUiOverlayStyle.light.copyWith(
|
value: appSystemOverlayStyle,
|
||||||
statusBarColor: Colors.transparent,
|
|
||||||
systemNavigationBarColor: AppColors.bar,
|
|
||||||
systemNavigationBarIconBrightness: Brightness.light,
|
|
||||||
),
|
|
||||||
child: child ?? const SizedBox.shrink(),
|
child: child ?? const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
home: const HomePage(),
|
home: const HomePage(),
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
@@ -9,20 +11,36 @@ import 'package:flutter/foundation.dart';
|
|||||||
class AppConfig {
|
class AppConfig {
|
||||||
const AppConfig._();
|
const AppConfig._();
|
||||||
|
|
||||||
/// 真机默认使用的服务地址。
|
/// 真机默认使用的开发机局域网地址。
|
||||||
///
|
///
|
||||||
/// 当前指向公网服务;换地址时改这一行。
|
/// 这个地址跟着电脑所在的网络走,换网络后要用
|
||||||
static const String devHostBaseUrl = 'http://39.162.50.99:8081';
|
/// `ipconfig getifaddr en0` 重新确认。默认值只改这一行。
|
||||||
|
static const String devHostBaseUrl = 'http://192.168.31.187:8081';
|
||||||
|
|
||||||
/// 双网络探测的首选地址,当前与默认地址一致(公网服务)。
|
/// 本机后端地址:通过 `adb reverse tcp:8081 tcp:8081` 映射到开发机,
|
||||||
static const String lanBaseUrl = 'http://39.162.50.99:8081';
|
/// 探测可达时 App 直连本机服务;不可达时回落到公网地址。
|
||||||
|
static const String lanBaseUrl = 'http://127.0.0.1:8081';
|
||||||
|
|
||||||
static const String _loopbackBaseUrl = 'http://127.0.0.1:8081';
|
/// 公网地址(远程/移动网络访问时使用)。
|
||||||
|
static const String publicBaseUrl = 'http://39.162.50.99:8081';
|
||||||
|
|
||||||
static const String _overrideBaseUrl = String.fromEnvironment('API_BASE_URL');
|
static const String _overrideBaseUrl = String.fromEnvironment('API_BASE_URL');
|
||||||
|
|
||||||
static String _runtimeBaseUrl = '';
|
static String _runtimeBaseUrl = '';
|
||||||
|
|
||||||
|
static Future<void>? _ready;
|
||||||
|
|
||||||
|
/// 启动地址探测完成信号;默认已完成,避免影响既有测试与调用方。
|
||||||
|
static Future<void> get ready => _ready ?? Future<void>.value();
|
||||||
|
static set ready(Future<void> value) => _ready = value;
|
||||||
|
|
||||||
|
static const Duration _probeTimeout = Duration(seconds: 2);
|
||||||
|
static const List<String> _probeCandidates = [
|
||||||
|
lanBaseUrl,
|
||||||
|
devHostBaseUrl,
|
||||||
|
publicBaseUrl,
|
||||||
|
];
|
||||||
|
|
||||||
/// Build-time server URL supplied through `--dart-define=API_BASE_URL`.
|
/// Build-time server URL supplied through `--dart-define=API_BASE_URL`.
|
||||||
static String get compiledBaseUrl => _overrideBaseUrl;
|
static String get compiledBaseUrl => _overrideBaseUrl;
|
||||||
|
|
||||||
@@ -35,7 +53,7 @@ class AppConfig {
|
|||||||
: (compiledBaseUrl.isNotEmpty ? compiledBaseUrl : defaultBaseUrl);
|
: (compiledBaseUrl.isNotEmpty ? compiledBaseUrl : defaultBaseUrl);
|
||||||
|
|
||||||
static final String _defaultBaseUrl = _runsOnDevMachine
|
static final String _defaultBaseUrl = _runsOnDevMachine
|
||||||
? _loopbackBaseUrl
|
? lanBaseUrl
|
||||||
: devHostBaseUrl;
|
: devHostBaseUrl;
|
||||||
|
|
||||||
/// 模拟器、桌面端与开发机同机,环回地址可用;iOS 真机不满足。
|
/// 模拟器、桌面端与开发机同机,环回地址可用;iOS 真机不满足。
|
||||||
@@ -61,28 +79,85 @@ class AppConfig {
|
|||||||
|
|
||||||
static const Duration requestTimeout = Duration(seconds: 8);
|
static const Duration requestTimeout = Duration(seconds: 8);
|
||||||
|
|
||||||
/// 探测内网地址是否可达,可达则切换为内网直连,否则回落到公网地址。
|
/// 按环回、开发机、公网顺序探测,写入首个返回 200 的地址。
|
||||||
///
|
///
|
||||||
/// Wi-Fi 下内网直连更快;移动数据下内网不可达,自动回落到公网端口映射。
|
/// 全部不可达时使用平台默认地址,真机保留开发机地址,避免退回公网。
|
||||||
static Future<void> probeAndApplyBaseUrl() async {
|
static Future<void> probeAndApplyBaseUrl({
|
||||||
try {
|
Future<bool> Function(String baseUrl)? probe,
|
||||||
final client = HttpClient()
|
String? fallbackBaseUrl,
|
||||||
..connectionTimeout = const Duration(seconds: 2);
|
|
||||||
final request = await client.getUrl(
|
/// 编译期地址测试注入;未传时读取 `API_BASE_URL`。
|
||||||
Uri.parse('$lanBaseUrl/app/categories'),
|
String? compiledOverride,
|
||||||
);
|
}) async {
|
||||||
final response = await request.close().timeout(const Duration(seconds: 2));
|
final compiled = compiledOverride ?? compiledBaseUrl;
|
||||||
await response.drain<void>();
|
if (compiled.isNotEmpty) {
|
||||||
client.close(force: true);
|
if (_runtimeBaseUrl.isEmpty) applyRuntimeBaseUrl(compiled);
|
||||||
if (response.statusCode == 200) {
|
return;
|
||||||
applyRuntimeBaseUrl(lanBaseUrl);
|
}
|
||||||
|
final candidates = <String>[
|
||||||
|
if (_runtimeBaseUrl.isNotEmpty) _runtimeBaseUrl,
|
||||||
|
for (final candidate in _probeCandidates)
|
||||||
|
if (candidate != _runtimeBaseUrl) candidate,
|
||||||
|
];
|
||||||
|
final results = await Future.wait([
|
||||||
|
for (final candidate in candidates)
|
||||||
|
probe == null
|
||||||
|
? _probeBaseUrl(candidate)
|
||||||
|
: _runInjectedProbe(candidate, probe),
|
||||||
|
]);
|
||||||
|
for (var index = 0; index < candidates.length; index++) {
|
||||||
|
final candidate = candidates[index];
|
||||||
|
final result = results[index];
|
||||||
|
if (kDebugMode) {
|
||||||
|
final status = result.ok
|
||||||
|
? 'ok'
|
||||||
|
: 'fail(${result.reason ?? 'request failed'})';
|
||||||
|
debugPrint('[probe] $candidate -> $status');
|
||||||
|
}
|
||||||
|
if (result.ok) {
|
||||||
|
applyRuntimeBaseUrl(candidate);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (_) {
|
|
||||||
// 内网不可达,回落到公网。
|
|
||||||
}
|
}
|
||||||
applyRuntimeBaseUrl(
|
applyRuntimeBaseUrl(fallbackBaseUrl ?? defaultBaseUrl);
|
||||||
compiledBaseUrl.isNotEmpty ? compiledBaseUrl : defaultBaseUrl,
|
}
|
||||||
|
|
||||||
|
static Future<_ProbeResult> _runInjectedProbe(
|
||||||
|
String baseUrl,
|
||||||
|
Future<bool> Function(String baseUrl) probe,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
return _ProbeResult(ok: await probe(baseUrl));
|
||||||
|
} catch (error) {
|
||||||
|
return _ProbeResult(ok: false, reason: error.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<_ProbeResult> _probeBaseUrl(String baseUrl) async {
|
||||||
|
final client = HttpClient()..connectionTimeout = _probeTimeout;
|
||||||
|
try {
|
||||||
|
return await _requestCategories(
|
||||||
|
client,
|
||||||
|
Uri.parse('$baseUrl/app/categories'),
|
||||||
|
).timeout(_probeTimeout);
|
||||||
|
} catch (error) {
|
||||||
|
return _ProbeResult(ok: false, reason: error.toString());
|
||||||
|
} finally {
|
||||||
|
client.close(force: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<_ProbeResult> _requestCategories(
|
||||||
|
HttpClient client,
|
||||||
|
Uri uri,
|
||||||
|
) async {
|
||||||
|
final request = await client.getUrl(uri);
|
||||||
|
final response = await request.close();
|
||||||
|
await response.drain<void>();
|
||||||
|
final ok = response.statusCode == 200;
|
||||||
|
return _ProbeResult(
|
||||||
|
ok: ok,
|
||||||
|
reason: ok ? null : 'HTTP ${response.statusCode}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,3 +168,10 @@ class AppConfig {
|
|||||||
return '$apiBaseUrl$path';
|
return '$apiBaseUrl$path';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ProbeResult {
|
||||||
|
const _ProbeResult({required this.ok, this.reason});
|
||||||
|
|
||||||
|
final bool ok;
|
||||||
|
final String? reason;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
/// 将系统语言解析为受支持的语言;不支持时回退印尼语。
|
||||||
|
Locale resolveAppLocale(Locale? deviceLocale) {
|
||||||
|
return switch (deviceLocale?.languageCode) {
|
||||||
|
'zh' => const Locale('zh'),
|
||||||
|
'en' => const Locale('en'),
|
||||||
|
_ => const Locale('id'),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
|
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
/// Resolves the selected or system language to one of the supported app locales.
|
|
||||||
///
|
|
||||||
/// Indonesian is the launch-market fallback when a device uses an unsupported
|
|
||||||
/// language. The legacy `in` code is treated as Indonesian as well.
|
|
||||||
Locale resolveAppLocale(Locale? selected, Locale? deviceLocale) {
|
|
||||||
final code = (selected ?? deviceLocale)?.languageCode;
|
|
||||||
return switch (code) {
|
|
||||||
'zh' => const Locale('zh'),
|
|
||||||
'en' => const Locale('en'),
|
|
||||||
_ => const Locale('id'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class LocaleController extends ChangeNotifier {
|
|
||||||
LocaleController({SharedPreferencesAsync? preferences})
|
|
||||||
: _preferences = preferences ?? SharedPreferencesAsync();
|
|
||||||
|
|
||||||
final SharedPreferencesAsync _preferences;
|
|
||||||
Locale? _locale;
|
|
||||||
Locale? get locale => _locale;
|
|
||||||
|
|
||||||
Future<void> load() async {
|
|
||||||
try {
|
|
||||||
final code = await _preferences.getString('ui.locale');
|
|
||||||
_locale = switch (code) {
|
|
||||||
'en' => const Locale('en'),
|
|
||||||
'zh' => const Locale('zh'),
|
|
||||||
'id' => const Locale('id'),
|
|
||||||
_ => null,
|
|
||||||
};
|
|
||||||
notifyListeners();
|
|
||||||
} catch (_) {
|
|
||||||
// The system locale remains usable if preferences are unavailable.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> select(String code) async {
|
|
||||||
_locale = switch (code) {
|
|
||||||
'en' => const Locale('en'),
|
|
||||||
'zh' => const Locale('zh'),
|
|
||||||
'id' => const Locale('id'),
|
|
||||||
_ => null,
|
|
||||||
};
|
|
||||||
notifyListeners();
|
|
||||||
try {
|
|
||||||
await _preferences.setString('ui.locale', code);
|
|
||||||
} catch (_) {
|
|
||||||
// Preference persistence is best effort for this prototype.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -69,6 +69,7 @@ abstract final class ServerEndpoint {
|
|||||||
preferenceKey,
|
preferenceKey,
|
||||||
normalized,
|
normalized,
|
||||||
);
|
);
|
||||||
|
AppConfig.applyRuntimeBaseUrl(normalized);
|
||||||
return true;
|
return true;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ class Drama {
|
|||||||
this.freeEpisodes = 0,
|
this.freeEpisodes = 0,
|
||||||
this.isHot = false,
|
this.isHot = false,
|
||||||
this.viewCount = 0,
|
this.viewCount = 0,
|
||||||
|
this.likeCount,
|
||||||
|
this.favoriteCount,
|
||||||
|
this.commentCount,
|
||||||
|
this.language = '',
|
||||||
this.detailLoaded = false,
|
this.detailLoaded = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -80,6 +84,14 @@ class Drama {
|
|||||||
final bool isHot;
|
final bool isHot;
|
||||||
final int viewCount;
|
final int viewCount;
|
||||||
|
|
||||||
|
/// 详情下发的互动数量;卡片或旧接口没有这些字段时为 null(客户端不显示数字)。
|
||||||
|
final int? likeCount;
|
||||||
|
final int? favoriteCount;
|
||||||
|
final int? commentCount;
|
||||||
|
|
||||||
|
/// 卡片语种(如 zh-CN / id),用于剧场语言筛选。
|
||||||
|
final String language;
|
||||||
|
|
||||||
/// 详情接口是否已经取过完整剧集列表。
|
/// 详情接口是否已经取过完整剧集列表。
|
||||||
/// 列表卡片只带一集可播预览,需要完整剧集时再进详情。
|
/// 列表卡片只带一集可播预览,需要完整剧集时再进详情。
|
||||||
final bool detailLoaded;
|
final bool detailLoaded;
|
||||||
@@ -104,6 +116,10 @@ class Drama {
|
|||||||
isHot: json['isHot'] == true,
|
isHot: json['isHot'] == true,
|
||||||
// viewCount 后端是 Long,按字符串下发(见 json_numbers.dart)
|
// viewCount 后端是 Long,按字符串下发(见 json_numbers.dart)
|
||||||
viewCount: jsonToInt(json['viewCount']),
|
viewCount: jsonToInt(json['viewCount']),
|
||||||
|
likeCount: _optionalInt(json['likeCount']),
|
||||||
|
favoriteCount: _optionalInt(json['favoriteCount']),
|
||||||
|
commentCount: _optionalInt(json['commentCount']),
|
||||||
|
language: json['language'] as String? ?? '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +136,10 @@ class Drama {
|
|||||||
freeEpisodes: (json['freeEpisodes'] as num?)?.toInt() ?? 0,
|
freeEpisodes: (json['freeEpisodes'] as num?)?.toInt() ?? 0,
|
||||||
isHot: json['isHot'] == true,
|
isHot: json['isHot'] == true,
|
||||||
viewCount: jsonToInt(json['viewCount']),
|
viewCount: jsonToInt(json['viewCount']),
|
||||||
|
likeCount: _optionalInt(json['likeCount']),
|
||||||
|
favoriteCount: _optionalInt(json['favoriteCount']),
|
||||||
|
commentCount: _optionalInt(json['commentCount']),
|
||||||
|
language: json['language'] as String? ?? '',
|
||||||
detailLoaded: true,
|
detailLoaded: true,
|
||||||
episodes: (json['episodes'] as List? ?? const [])
|
episodes: (json['episodes'] as List? ?? const [])
|
||||||
.whereType<Map<String, dynamic>>()
|
.whereType<Map<String, dynamic>>()
|
||||||
@@ -128,3 +148,5 @@ class Drama {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int? _optionalInt(dynamic value) => value == null ? null : jsonToInt(value);
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'json_numbers.dart';
|
||||||
|
|
||||||
|
/// 充值订单:`POST /app/orders` 创建的 `CREATED` 订单,等待支付。
|
||||||
|
class AppOrder {
|
||||||
|
const AppOrder({
|
||||||
|
required this.id,
|
||||||
|
required this.orderNo,
|
||||||
|
required this.productName,
|
||||||
|
required this.coinAmount,
|
||||||
|
required this.status,
|
||||||
|
this.productId = '',
|
||||||
|
this.type = '',
|
||||||
|
this.amount = '',
|
||||||
|
this.currency = '',
|
||||||
|
this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const String created = 'CREATED';
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String orderNo;
|
||||||
|
final String productId;
|
||||||
|
final String productName;
|
||||||
|
final String type;
|
||||||
|
final int coinAmount;
|
||||||
|
final String amount;
|
||||||
|
final String currency;
|
||||||
|
final String status;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
|
||||||
|
bool get isCreated => status == created;
|
||||||
|
|
||||||
|
factory AppOrder.fromJson(Map<String, dynamic> json) {
|
||||||
|
return AppOrder(
|
||||||
|
id: (json['id'] ?? '').toString(),
|
||||||
|
orderNo: json['orderNo'] as String? ?? '',
|
||||||
|
productId: (json['productId'] ?? '').toString(),
|
||||||
|
productName: json['productName'] as String? ?? '',
|
||||||
|
type: json['type'] as String? ?? '',
|
||||||
|
coinAmount: jsonToInt(json['coinAmount']),
|
||||||
|
amount: (json['amount'] ?? '').toString(),
|
||||||
|
currency: json['currency'] as String? ?? '',
|
||||||
|
status: json['status'] as String? ?? '',
|
||||||
|
createdAt: DateTime.tryParse(json['createdAt'] as String? ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ class PlayTicket {
|
|||||||
this.durationSec = 0,
|
this.durationSec = 0,
|
||||||
this.unlockCoin = 0,
|
this.unlockCoin = 0,
|
||||||
this.previewSeconds = 0,
|
this.previewSeconds = 0,
|
||||||
|
this.qualities = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
static const String free = 'FREE';
|
static const String free = 'FREE';
|
||||||
@@ -35,6 +36,9 @@ class PlayTicket {
|
|||||||
/// 试看秒数:仅 `COIN_REQUIRED` 时有意义(付费解锁在 B2 做)。
|
/// 试看秒数:仅 `COIN_REQUIRED` 时有意义(付费解锁在 B2 做)。
|
||||||
final int previewSeconds;
|
final int previewSeconds;
|
||||||
|
|
||||||
|
/// 后端下发的清晰度档位(如 AUTO / 720P / 1080P),HLS 由播放器自动切换。
|
||||||
|
final List<String> qualities;
|
||||||
|
|
||||||
bool get needsUnlock =>
|
bool get needsUnlock =>
|
||||||
accessType == coinRequired || accessType == vipRequired;
|
accessType == coinRequired || accessType == vipRequired;
|
||||||
|
|
||||||
@@ -50,6 +54,10 @@ class PlayTicket {
|
|||||||
durationSec: jsonToInt(json['durationSec']),
|
durationSec: jsonToInt(json['durationSec']),
|
||||||
unlockCoin: jsonToInt(json['unlockCoin']),
|
unlockCoin: jsonToInt(json['unlockCoin']),
|
||||||
previewSeconds: jsonToInt(json['previewSeconds']),
|
previewSeconds: jsonToInt(json['previewSeconds']),
|
||||||
|
qualities: (json['qualities'] as List? ?? const [])
|
||||||
|
.whereType<String>()
|
||||||
|
.where((quality) => quality.isNotEmpty)
|
||||||
|
.toList(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'json_numbers.dart';
|
||||||
|
|
||||||
|
/// 充值商品:`GET /app/products` 的 `COIN_PACK` / `VIP` 条目。
|
||||||
|
class AppProduct {
|
||||||
|
const AppProduct({
|
||||||
|
required this.id,
|
||||||
|
required this.code,
|
||||||
|
required this.name,
|
||||||
|
required this.type,
|
||||||
|
this.coinAmount = 0,
|
||||||
|
this.bonusCoin = 0,
|
||||||
|
this.totalCoin = 0,
|
||||||
|
this.displayPrice = '',
|
||||||
|
this.currency = '',
|
||||||
|
this.vipDays = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const String coinPackType = 'COIN_PACK';
|
||||||
|
static const String vipType = 'VIP';
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String code;
|
||||||
|
final String name;
|
||||||
|
final String type;
|
||||||
|
final int coinAmount;
|
||||||
|
final int bonusCoin;
|
||||||
|
final int totalCoin;
|
||||||
|
final String displayPrice;
|
||||||
|
final String currency;
|
||||||
|
final int vipDays;
|
||||||
|
|
||||||
|
bool get isVip => type == vipType;
|
||||||
|
|
||||||
|
factory AppProduct.fromJson(Map<String, dynamic> json) {
|
||||||
|
final coinAmount = jsonToInt(json['coinAmount']);
|
||||||
|
final bonusCoin = jsonToInt(json['bonusCoin']);
|
||||||
|
return AppProduct(
|
||||||
|
id: (json['id'] ?? '').toString(),
|
||||||
|
code: json['code'] as String? ?? '',
|
||||||
|
name: json['name'] as String? ?? '',
|
||||||
|
type: json['type'] as String? ?? '',
|
||||||
|
coinAmount: coinAmount,
|
||||||
|
bonusCoin: bonusCoin,
|
||||||
|
totalCoin: json['totalCoin'] == null
|
||||||
|
? coinAmount + bonusCoin
|
||||||
|
: jsonToInt(json['totalCoin']),
|
||||||
|
displayPrice: json['displayPrice'] as String? ?? '',
|
||||||
|
currency: json['currency'] as String? ?? '',
|
||||||
|
vipDays: jsonToInt(json['vipDays']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'drama.dart';
|
||||||
|
import 'json_numbers.dart';
|
||||||
|
|
||||||
|
/// 搜索分页结果:`GET /app/search` 的 `{total, list}`。
|
||||||
|
class SearchResult {
|
||||||
|
const SearchResult({this.total = 0, this.list = const []});
|
||||||
|
|
||||||
|
final int total;
|
||||||
|
final List<Drama> list;
|
||||||
|
|
||||||
|
factory SearchResult.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SearchResult(
|
||||||
|
total: jsonToInt(json['total']),
|
||||||
|
list: (json['list'] as List? ?? const [])
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(Drama.fromCardJson)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'json_numbers.dart';
|
||||||
|
|
||||||
|
/// 金币钱包与流水:对应 `GET /app/wallet` 的 `wallet` / `ledger`。
|
||||||
|
class CoinWallet {
|
||||||
|
const CoinWallet({
|
||||||
|
required this.coinBalance,
|
||||||
|
required this.totalRechargedCoin,
|
||||||
|
required this.totalConsumedCoin,
|
||||||
|
required this.totalRefundedCoin,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int coinBalance;
|
||||||
|
final int totalRechargedCoin;
|
||||||
|
final int totalConsumedCoin;
|
||||||
|
final int totalRefundedCoin;
|
||||||
|
|
||||||
|
factory CoinWallet.fromJson(Map<String, dynamic> json) {
|
||||||
|
return CoinWallet(
|
||||||
|
coinBalance: jsonToInt(json['coinBalance']),
|
||||||
|
totalRechargedCoin: jsonToInt(json['totalRechargedCoin']),
|
||||||
|
totalConsumedCoin: jsonToInt(json['totalConsumedCoin']),
|
||||||
|
totalRefundedCoin: jsonToInt(json['totalRefundedCoin']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一笔金币变动;[changeAmount] 正数入账、负数出账。
|
||||||
|
class CoinLedgerEntry {
|
||||||
|
const CoinLedgerEntry({
|
||||||
|
required this.id,
|
||||||
|
required this.changeAmount,
|
||||||
|
required this.balanceAfter,
|
||||||
|
required this.bizType,
|
||||||
|
required this.remark,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final int changeAmount;
|
||||||
|
final int balanceAfter;
|
||||||
|
final String bizType;
|
||||||
|
final String remark;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
|
||||||
|
factory CoinLedgerEntry.fromJson(Map<String, dynamic> json) {
|
||||||
|
return CoinLedgerEntry(
|
||||||
|
id: (json['id'] ?? '').toString(),
|
||||||
|
changeAmount: jsonToInt(json['changeAmount']),
|
||||||
|
balanceAfter: jsonToInt(json['balanceAfter']),
|
||||||
|
bizType: json['bizType'] as String? ?? '',
|
||||||
|
remark: json['remark'] as String? ?? '',
|
||||||
|
createdAt: DateTime.tryParse(json['createdAt'] as String? ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 钱包页一次渲染所需的余额与最近流水。
|
||||||
|
class WalletSnapshot {
|
||||||
|
const WalletSnapshot({required this.wallet, this.ledger = const []});
|
||||||
|
|
||||||
|
final CoinWallet wallet;
|
||||||
|
final List<CoinLedgerEntry> ledger;
|
||||||
|
|
||||||
|
factory WalletSnapshot.fromJson(Map<String, dynamic> json) {
|
||||||
|
final wallet = json['wallet'];
|
||||||
|
return WalletSnapshot(
|
||||||
|
wallet: wallet is Map<String, dynamic>
|
||||||
|
? CoinWallet.fromJson(wallet)
|
||||||
|
: const CoinWallet(
|
||||||
|
coinBalance: 0,
|
||||||
|
totalRechargedCoin: 0,
|
||||||
|
totalConsumedCoin: 0,
|
||||||
|
totalRefundedCoin: 0,
|
||||||
|
),
|
||||||
|
ledger: (json['ledger'] as List? ?? const [])
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(CoinLedgerEntry.fromJson)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 金币解锁结果:`POST /app/episodes/{id}/unlock`。
|
||||||
|
class UnlockResult {
|
||||||
|
const UnlockResult({
|
||||||
|
required this.unlocked,
|
||||||
|
required this.accessType,
|
||||||
|
required this.coinCost,
|
||||||
|
required this.coinBalance,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool unlocked;
|
||||||
|
final String accessType;
|
||||||
|
final int coinCost;
|
||||||
|
final int coinBalance;
|
||||||
|
|
||||||
|
factory UnlockResult.fromJson(Map<String, dynamic> json) {
|
||||||
|
return UnlockResult(
|
||||||
|
unlocked: json['unlocked'] == true,
|
||||||
|
accessType: json['accessType'] as String? ?? '',
|
||||||
|
coinCost: jsonToInt(json['coinCost']),
|
||||||
|
coinBalance: jsonToInt(json['coinBalance']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ class ApiClient {
|
|||||||
this.tokenRefresher,
|
this.tokenRefresher,
|
||||||
this.onUnauthorized,
|
this.onUnauthorized,
|
||||||
this.deviceIdReader,
|
this.deviceIdReader,
|
||||||
}) : baseUrl = baseUrl ?? AppConfig.apiBaseUrl {
|
}) : _fixedBaseUrl = baseUrl {
|
||||||
_dio = Dio(
|
_dio = Dio(
|
||||||
BaseOptions(
|
BaseOptions(
|
||||||
baseUrl: this.baseUrl,
|
baseUrl: this.baseUrl,
|
||||||
@@ -45,9 +45,12 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final String baseUrl;
|
final String? _fixedBaseUrl;
|
||||||
late final Dio _dio;
|
late final Dio _dio;
|
||||||
|
|
||||||
|
/// 当前请求使用的服务地址;未显式指定时跟随启动探测结果。
|
||||||
|
String get baseUrl => _fixedBaseUrl ?? AppConfig.apiBaseUrl;
|
||||||
|
|
||||||
/// 取当前 access token(未登录返回 null)
|
/// 取当前 access token(未登录返回 null)
|
||||||
final String? Function()? accessTokenReader;
|
final String? Function()? accessTokenReader;
|
||||||
|
|
||||||
@@ -110,6 +113,7 @@ class ApiClient {
|
|||||||
bool retried = false,
|
bool retried = false,
|
||||||
bool skipAuthRetry = false,
|
bool skipAuthRetry = false,
|
||||||
}) async {
|
}) async {
|
||||||
|
_syncBaseUrl();
|
||||||
final disableAuthRetry = skipAuthRetry || tokenRefresher == null;
|
final disableAuthRetry = skipAuthRetry || tokenRefresher == null;
|
||||||
try {
|
try {
|
||||||
final response = await _dio.request<Object?>(
|
final response = await _dio.request<Object?>(
|
||||||
@@ -150,5 +154,10 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _syncBaseUrl() {
|
||||||
|
if (_fixedBaseUrl != null) return;
|
||||||
|
_dio.options.baseUrl = AppConfig.apiBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
void close() => _dio.close(force: true);
|
void close() => _dio.close(force: true);
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,67 @@ class AuthApi {
|
|||||||
|
|
||||||
final ApiClient _client;
|
final ApiClient _client;
|
||||||
|
|
||||||
|
/// 发送短信验证码;[scene] 为 REGISTER / LOGIN / RESET_PWD / BIND。
|
||||||
|
Future<void> sendOtp({
|
||||||
|
required String target,
|
||||||
|
required String scene,
|
||||||
|
required String deviceId,
|
||||||
|
}) async {
|
||||||
|
await _client.post(
|
||||||
|
'/app/auth/otp',
|
||||||
|
body: {
|
||||||
|
'scene': scene,
|
||||||
|
'targetType': 'PHONE',
|
||||||
|
'target': target,
|
||||||
|
'deviceId': deviceId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 手机号 + 验证码登录。
|
||||||
|
Future<AuthSession> loginWithPhone({
|
||||||
|
required String target,
|
||||||
|
required String code,
|
||||||
|
required String deviceId,
|
||||||
|
String platform = '',
|
||||||
|
String appVersion = '',
|
||||||
|
}) async {
|
||||||
|
final data = await _client.post(
|
||||||
|
'/app/auth/login',
|
||||||
|
body: {
|
||||||
|
'targetType': 'PHONE',
|
||||||
|
'target': target,
|
||||||
|
'code': code,
|
||||||
|
'deviceId': deviceId,
|
||||||
|
'platform': platform,
|
||||||
|
'appVersion': appVersion,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return AuthSession.fromJson((data as Map).cast<String, dynamic>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 手机号 + 验证码注册。
|
||||||
|
Future<AuthSession> registerWithPhone({
|
||||||
|
required String target,
|
||||||
|
required String code,
|
||||||
|
required String deviceId,
|
||||||
|
String platform = '',
|
||||||
|
String appVersion = '',
|
||||||
|
}) async {
|
||||||
|
final data = await _client.post(
|
||||||
|
'/app/auth/register',
|
||||||
|
body: {
|
||||||
|
'targetType': 'PHONE',
|
||||||
|
'target': target,
|
||||||
|
'code': code,
|
||||||
|
'deviceId': deviceId,
|
||||||
|
'platform': platform,
|
||||||
|
'appVersion': appVersion,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return AuthSession.fromJson((data as Map).cast<String, dynamic>());
|
||||||
|
}
|
||||||
|
|
||||||
Future<AuthSession> register({
|
Future<AuthSession> register({
|
||||||
required String email,
|
required String email,
|
||||||
required String password,
|
required String password,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import '../models/drama.dart';
|
import '../models/drama.dart';
|
||||||
import '../models/home_feed.dart';
|
import '../models/home_feed.dart';
|
||||||
|
import '../models/search_result.dart';
|
||||||
import 'api_client.dart';
|
import 'api_client.dart';
|
||||||
|
|
||||||
/// 客户端目录接口,对应后端 `/app/**`。
|
/// 客户端目录接口,对应后端 `/app/**`。
|
||||||
@@ -16,6 +17,23 @@ class CatalogApi {
|
|||||||
return HomeFeed.fromJson(_asMap(await _client.get('/app/discover/feed')));
|
return HomeFeed.fromJson(_asMap(await _client.get('/app/discover/feed')));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表。
|
||||||
|
Future<SearchResult> search(
|
||||||
|
String keyword, {
|
||||||
|
int pageIndex = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
}) async {
|
||||||
|
final data = await _client.get(
|
||||||
|
'/app/search',
|
||||||
|
query: {
|
||||||
|
'keyword': keyword,
|
||||||
|
'pageIndex': '$pageIndex',
|
||||||
|
'pageSize': '$pageSize',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return SearchResult.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
Future<Drama> dramaDetail(String dramaId) async {
|
Future<Drama> dramaDetail(String dramaId) async {
|
||||||
return Drama.fromDetailJson(
|
return Drama.fromDetailJson(
|
||||||
_asMap(await _client.get('/app/dramas/$dramaId')),
|
_asMap(await _client.get('/app/dramas/$dramaId')),
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import '../models/order.dart';
|
||||||
|
import '../models/product.dart';
|
||||||
|
import '../models/wallet.dart';
|
||||||
|
import 'api_client.dart';
|
||||||
|
|
||||||
|
/// 金币与解锁接口,对应契约 §1.8。
|
||||||
|
class CommerceApi {
|
||||||
|
const CommerceApi(this._client);
|
||||||
|
|
||||||
|
final ApiClient _client;
|
||||||
|
|
||||||
|
Future<WalletSnapshot> wallet() async {
|
||||||
|
final data = await _client.get('/app/wallet');
|
||||||
|
if (data is Map<String, dynamic>) return WalletSnapshot.fromJson(data);
|
||||||
|
throw const ApiException(-1, '返回数据格式异常');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上架商品列表;`type` 传 `COIN_PACK` 或 `VIP`。
|
||||||
|
Future<List<AppProduct>> products({String? type}) async {
|
||||||
|
final data = await _client.get(
|
||||||
|
'/app/products',
|
||||||
|
query: type == null ? null : {'type': type},
|
||||||
|
);
|
||||||
|
if (data is List) {
|
||||||
|
return data
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(AppProduct.fromJson)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
throw const ApiException(-1, '返回数据格式异常');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建充值订单,返回 `CREATED` 状态订单。
|
||||||
|
Future<AppOrder> createOrder(String productId) async {
|
||||||
|
final data = await _client.post(
|
||||||
|
'/app/orders',
|
||||||
|
body: {'productId': productId},
|
||||||
|
);
|
||||||
|
if (data is Map<String, dynamic>) return AppOrder.fromJson(data);
|
||||||
|
throw const ApiException(-1, '返回数据格式异常');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<UnlockResult> unlock(String episodeId) async {
|
||||||
|
final data = await _client.post('/app/episodes/$episodeId/unlock');
|
||||||
|
if (data is Map<String, dynamic>) return UnlockResult.fromJson(data);
|
||||||
|
throw const ApiException(-1, '返回数据格式异常');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import '../models/wallet.dart';
|
||||||
|
import '../remote/commerce_api.dart';
|
||||||
|
|
||||||
|
/// 钱包与金币解锁的数据来源。
|
||||||
|
abstract interface class CommerceRepository {
|
||||||
|
Future<WalletSnapshot> fetchWallet();
|
||||||
|
|
||||||
|
Future<UnlockResult> unlock(String episodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
class HttpCommerceRepository implements CommerceRepository {
|
||||||
|
const HttpCommerceRepository(this._api);
|
||||||
|
|
||||||
|
final CommerceApi _api;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<WalletSnapshot> fetchWallet() => _api.wallet();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<UnlockResult> unlock(String episodeId) => _api.unlock(episodeId);
|
||||||
|
}
|
||||||
@@ -59,7 +59,8 @@ class _CachedTicket {
|
|||||||
final PlayTicket ticket;
|
final PlayTicket ticket;
|
||||||
final DateTime fetchedAt;
|
final DateTime fetchedAt;
|
||||||
|
|
||||||
bool isExpired(DateTime now, Duration ttl) => now.difference(fetchedAt) >= ttl;
|
bool isExpired(DateTime now, Duration ttl) =>
|
||||||
|
now.difference(fetchedAt) >= ttl;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试与离线场景用:按集返回固定票据,并记录被请求过几次。
|
/// 测试与离线场景用:按集返回固定票据,并记录被请求过几次。
|
||||||
@@ -91,6 +92,7 @@ class FakePlaybackRepository implements PlaybackRepository {
|
|||||||
accessible: true,
|
accessible: true,
|
||||||
accessType: PlayTicket.free,
|
accessType: PlayTicket.free,
|
||||||
playUrl: '/files/episode/fake-$episodeId.mp4?e=1&s=abc',
|
playUrl: '/files/episode/fake-$episodeId.mp4?e=1&s=abc',
|
||||||
|
qualities: const ['AUTO', '720P', '1080P'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import '../models/search_result.dart';
|
||||||
|
import '../remote/catalog_api.dart';
|
||||||
|
|
||||||
|
/// 搜索数据来源:只调用后端 `/app/search`,不在客户端做本地过滤。
|
||||||
|
abstract interface class SearchRepository {
|
||||||
|
Future<SearchResult> search(
|
||||||
|
String keyword, {
|
||||||
|
int pageIndex = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class HttpSearchRepository implements SearchRepository {
|
||||||
|
const HttpSearchRepository(this._api);
|
||||||
|
|
||||||
|
final CatalogApi _api;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SearchResult> search(
|
||||||
|
String keyword, {
|
||||||
|
int pageIndex = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
}) => _api.search(keyword, pageIndex: pageIndex, pageSize: pageSize);
|
||||||
|
}
|
||||||