feat: 完成智能电网电工端 Flutter 应用初始化
创建完整的 Flutter 项目结构,包含 Android 和 iOS 平台配置、核心网络层、路由、状态管理及业务模块(订单、收益、认证),适配原 uni-app 项目功能逻辑
No files matched your search
@@ -0,0 +1,45 @@
|
||||
# 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/
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1,33 @@
|
||||
# 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: "00b0c91f06209d9e4a41f71b7a512d6eb3b9c694"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||
- platform: android
|
||||
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||
- platform: ios
|
||||
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||
|
||||
# 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'
|
||||
@@ -0,0 +1,17 @@
|
||||
# smart_grid_iot_app_flutter
|
||||
|
||||
A new Flutter project.
|
||||
|
||||
## Getting Started
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
|
||||
A few resources to get you started if this is your first Flutter project:
|
||||
|
||||
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
|
||||
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
@@ -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
|
||||
@@ -0,0 +1,14 @@
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
@@ -0,0 +1,44 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.voltlink.smart_grid_iot_app_flutter"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.voltlink.smart_grid_iot_app_flutter"
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,45 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="smart_grid_iot_app_flutter"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.voltlink.smart_grid_iot_app_flutter
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,24 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!-- Required meta tags -->
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
|
||||
<style type="text/css">
|
||||
/*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */
|
||||
html {
|
||||
line-height: 1.15;
|
||||
-ms-text-size-adjust: 100%;
|
||||
-webkit-text-size-adjust: 100%
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0
|
||||
}
|
||||
|
||||
article, aside, footer, header, nav, section {
|
||||
display: block
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
margin: .67em 0
|
||||
}
|
||||
|
||||
figcaption, figure, main {
|
||||
display: block
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 1em 40px
|
||||
}
|
||||
|
||||
hr {
|
||||
box-sizing: content-box;
|
||||
height: 0;
|
||||
overflow: visible
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family: monospace, monospace;
|
||||
font-size: 1em
|
||||
}
|
||||
|
||||
a {
|
||||
background-color: transparent;
|
||||
-webkit-text-decoration-skip: objects
|
||||
}
|
||||
|
||||
abbr[title] {
|
||||
border-bottom: none;
|
||||
text-decoration: underline;
|
||||
text-decoration: underline dotted
|
||||
}
|
||||
|
||||
b, strong {
|
||||
font-weight: inherit
|
||||
}
|
||||
|
||||
b, strong {
|
||||
font-weight: bolder
|
||||
}
|
||||
|
||||
code, kbd, samp {
|
||||
font-family: monospace, monospace;
|
||||
font-size: 1em
|
||||
}
|
||||
|
||||
dfn {
|
||||
font-style: italic
|
||||
}
|
||||
|
||||
mark {
|
||||
background-color: #ff0;
|
||||
color: #000
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 80%
|
||||
}
|
||||
|
||||
sub, sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -.25em
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -.5em
|
||||
}
|
||||
|
||||
audio, video {
|
||||
display: inline-block
|
||||
}
|
||||
|
||||
audio:not([controls]) {
|
||||
display: none;
|
||||
height: 0
|
||||
}
|
||||
|
||||
img {
|
||||
border-style: none
|
||||
}
|
||||
|
||||
svg:not(:root) {
|
||||
overflow: hidden
|
||||
}
|
||||
|
||||
button, input, optgroup, select, textarea {
|
||||
font-family: sans-serif;
|
||||
font-size: 100%;
|
||||
line-height: 1.15;
|
||||
margin: 0
|
||||
}
|
||||
|
||||
button, input {
|
||||
overflow: visible
|
||||
}
|
||||
|
||||
button, select {
|
||||
text-transform: none
|
||||
}
|
||||
|
||||
[type=reset], [type=submit], button, html [type=button] {
|
||||
-webkit-appearance: button
|
||||
}
|
||||
|
||||
[type=button]::-moz-focus-inner, [type=reset]::-moz-focus-inner, [type=submit]::-moz-focus-inner, button::-moz-focus-inner {
|
||||
border-style: none;
|
||||
padding: 0
|
||||
}
|
||||
|
||||
[type=button]:-moz-focusring, [type=reset]:-moz-focusring, [type=submit]:-moz-focusring, button:-moz-focusring {
|
||||
outline: 1px dotted ButtonText
|
||||
}
|
||||
|
||||
fieldset {
|
||||
padding: .35em .75em .625em
|
||||
}
|
||||
|
||||
legend {
|
||||
box-sizing: border-box;
|
||||
color: inherit;
|
||||
display: table;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
white-space: normal
|
||||
}
|
||||
|
||||
progress {
|
||||
display: inline-block;
|
||||
vertical-align: baseline
|
||||
}
|
||||
|
||||
textarea {
|
||||
overflow: auto
|
||||
}
|
||||
|
||||
[type=checkbox], [type=radio] {
|
||||
box-sizing: border-box;
|
||||
padding: 0
|
||||
}
|
||||
|
||||
[type=number]::-webkit-inner-spin-button, [type=number]::-webkit-outer-spin-button {
|
||||
height: auto
|
||||
}
|
||||
|
||||
[type=search] {
|
||||
-webkit-appearance: textfield;
|
||||
outline-offset: -2px
|
||||
}
|
||||
|
||||
[type=search]::-webkit-search-cancel-button, [type=search]::-webkit-search-decoration {
|
||||
-webkit-appearance: none
|
||||
}
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
-webkit-appearance: button;
|
||||
font: inherit
|
||||
}
|
||||
|
||||
details, menu {
|
||||
display: block
|
||||
}
|
||||
|
||||
summary {
|
||||
display: list-item
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: inline-block
|
||||
}
|
||||
|
||||
template {
|
||||
display: none
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none
|
||||
}
|
||||
|
||||
/* configuration cache styles */
|
||||
|
||||
.report-wrapper {
|
||||
margin: 0;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.gradle-logo {
|
||||
width: 32px;
|
||||
height: 24px;
|
||||
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAYCAYAAACbU/80AAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAIKADAAQAAAABAAAAGAAAAAA915G0AAAD5klEQVRIDbVWC0xTZxT+emmhVUEeA1/ROh/tFAFFGK7oJisIKsNVoOwBbJPowEWHzikRxeiMRpwwjDWRBHQLIzOmiRhe22BT40TitiyaMBQFfMEeLMIEaSmk+/+rvd7be4no6Elu7n++c/5zzv845/wyOyG4iGyDgzCdNOPLM9W41n4bnmNUiHo5DNsz0hGsmcV6lbkyAOOWXJjrz4qWp1C4o3z/LqzWL4VcJB1FIHmZHn/f78a6pDcxbeIEfNvQiPwTZbDZBpC24zOEaGfDpTsgtZby6u+QlrubFWUY3nh6AH39/ahr/Bn1jZfxW3ML2js60dtvgbtcQVblj8CZM7A0PBSrol6Ft+c4KZ8iTB1nwN0//8IEP9/hA2i924Gir0/iq8oa/NvbJzLiDKiUSqTE6pGVbEBY4BxnsYAPSnwXTa3tLCZ5BF3dPdAkGNHzoFcwcaRMnC4CeZkZiAgKFE252nITC1Pew9Dj5GNEGgS4Rbb5eZ1Te7UXG6FLX4cV6zeh5kIDaDpSunL9Boyf5nLOpwT4Sx+BxWrFK8QAnTAapPRQwofcj86uLoG59cbVEOzA0NAQNh38Atn5RSjY8rFAmc/I3dyQvOx1PsSNVy7Roa3ajHDePbBYLSLn1MaGd5KFAXy07xAOl59C6elK+I73hIHcbGd6wXs8qkyH8FZcjLOI5X/9/TrOnLsAldJDUu4As1NToFFPe3IEpm/M2HigwCFnU6t4Zw6Ck1JhGRhgcXq5juXloKyqFnlHirmz5CaNcEAv59kSE9wVikcB3O78A/MSU0Fznk/H9+yAetJEnPr+B8RFLsLcGS8ia28+qQuX+WrPNNZOV+Nc6VH4+3iz89g0pEaLzRUiQ3LGDWsM8Qidq2WL0PGKKlgf74ZIeQTAfFJ6a44WIsDXh9OW/dPdY58aawC9KK6kpOgolO7JxViVSuBGXnvxksudZ5F0O5yzGYxMJnBOGaau4fnPU2RNAtCFBKFoa7akczaAptY2iWmjB33+yQa4kZwfjpi2ex3Dyf43vuAljWQ/4Btmei1WPj+q45hF4U+1J4fEizCEvNf0EWHoIW244sfzoN1RipaT2kDfdjfv3MNpojdISjmfIheE8Fnp8WR9vJ2Zr+O+bYUmO+kJ9KnIUtf9bnvY2x9wcqrrvnCJvfL8Tw4V9v9LU7PdKzJaoNdy645AR4ph1JMncZHRKrVvYyYY5kmP8iO1v2T3dk6HDtYmrgJtOnwKnaPFrg8z+BBX7QSgEyOPJfX9Qd9DFs40GgTOHbrBs2ch4bXFuEG2mmFkeD9hpUMk+NMXEe0TNtsg/Ly94DVurEAuxfwHC1WiVbe0U7MAAAAASUVORK5CYII=");
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding: 24px 24px 0 24px;
|
||||
background-color: white;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.learn-more {
|
||||
margin-left: auto;
|
||||
align-self: center;
|
||||
font-size: 0.875rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 18px 0 24px 0;
|
||||
flex: 1 0 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 0.875rem;
|
||||
padding: 240px 0 48px;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.content ol:first-of-type {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tree-btn {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
vertical-align: middle;
|
||||
margin-top: -0.2em;
|
||||
}
|
||||
|
||||
.tree-btn.collapsed {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 512"><path d="M166.9 264.5l-117.8 116c-4.7 4.7-12.3 4.7-17 0l-7.1-7.1c-4.7-4.7-4.7-12.3 0-17L127.3 256 25.1 155.6c-4.7-4.7-4.7-12.3 0-17l7.1-7.1c4.7-4.7 12.3-4.7 17 0l117.8 116c4.6 4.7 4.6 12.3-.1 17z" fill="%23999999" stroke="%23999999"/></svg>');
|
||||
}
|
||||
|
||||
.tree-btn.expanded {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 512"><path d="M119.5 326.9L3.5 209.1c-4.7-4.7-4.7-12.3 0-17l7.1-7.1c4.7-4.7 12.3-4.7 17 0L128 287.3l100.4-102.2c4.7-4.7 12.3-4.7 17 0l7.1 7.1c4.7 4.7 4.7 12.3 0 17L136.5 327c-4.7 4.6-12.3 4.6-17-.1z" fill="%23999999" stroke="%23999999"/></svg>');
|
||||
}
|
||||
|
||||
ul .tree-btn {
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.leaf-icon {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 512"><path d="M32 256 H224" stroke="%23999999" stroke-width="48" stroke-linecap="round"/></svg>');
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
vertical-align: middle;
|
||||
margin-top: -0.2em;
|
||||
}
|
||||
|
||||
.invisible-text {
|
||||
user-select: all; /* Allow the text to be selectable */
|
||||
color: transparent; /* Hide the text */
|
||||
text-indent: -9999px; /* Move the text out of view */
|
||||
position: relative;
|
||||
white-space: pre; /* Preserve meaningful whitespace in the invisible text for copying */
|
||||
}
|
||||
|
||||
.text-for-copy {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.enum-icon {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="200" /></svg>');
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
vertical-align: middle;
|
||||
margin-inline-start: 0.5ex;
|
||||
margin-inline-end: 0.5ex;
|
||||
margin-top: -0.2em;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path d="M193.94 256L296.5 153.44l21.15-21.15c3.12-3.12 3.12-8.19 0-11.31l-22.63-22.63c-3.12-3.12-8.19-3.12-11.31 0L160 222.06 36.29 98.34c-3.12-3.12-8.19-3.12-11.31 0L2.34 120.97c-3.12 3.12-3.12 8.19 0 11.31L126.06 256 2.34 379.71c-3.12 3.12-3.12 8.19 0 11.31l22.63 22.63c3.12 3.12 8.19 3.12 11.31 0L160 289.94 262.56 392.5l21.15 21.15c3.12 3.12 8.19 3.12 11.31 0l22.63-22.63c3.12-3.12 3.12-8.19 0-11.31L193.94 256z" fill="%23FC461E" stroke="%23FC461E"/></svg>');
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
vertical-align: middle;
|
||||
margin-inline-start: 0.5ex;
|
||||
margin-inline-end: 0.5ex;
|
||||
margin-top: -0.2em;
|
||||
}
|
||||
|
||||
.advice-icon {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-image: url('data:image/svg+xml;utf8,<svg width="800px" height="800px" viewBox="-4.93 0 122.88 122.88" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="enable-background:new 0 0 113.01 122.88" xml:space="preserve"><g><path d="M44.13,102.06c-1.14,0.03-2.14-0.81-2.3-1.96c-0.17-1.2,0.64-2.31,1.82-2.54c-1.3-7.37-4.85-11.43-8.6-15.72 c-2.92-3.34-5.95-6.81-8.34-11.92c-2.35-5.03-3.64-10.23-3.6-15.63c0.05-5.4,1.42-10.96,4.4-16.71c0.02-0.04,0.04-0.07,0.06-0.11 l0,0c3.91-6.62,9.38-11.04,15.47-13.52c5.11-2.09,10.66-2.8,16.1-2.3c5.42,0.5,10.73,2.2,15.37,4.94 c5.9,3.49,10.75,8.67,13.42,15.21c1.44,3.54,2.42,7.49,2.54,11.82c0.12,4.31-0.62,8.96-2.61,13.88 c-2.66,6.59-6.18,10.68-9.47,14.51c-3.03,3.53-5.85,6.81-7.42,11.84c0.89,0.21,1.59,0.94,1.73,1.9c0.17,1.24-0.7,2.39-1.94,2.56 l-0.77,0.11c-0.14,1.09-0.23,2.26-0.27,3.51l0.25-0.04c1.24-0.17,2.39,0.7,2.56,1.94c0.17,1.24-0.7,2.39-1.94,2.56l-0.78,0.11 c0.01,0.15,0.02,0.3,0.03,0.45l0,0c0.07,0.88,0.08,1.73,0.03,2.54l0.13-0.02c1.25-0.15,2.38,0.74,2.54,1.98 c0.15,1.25-0.74,2.38-1.98,2.54l-1.68,0.21c-1.2,3.11-3.34,5.48-5.87,6.94c-1.74,1.01-3.67,1.59-5.61,1.71 c-1.97,0.12-3.96-0.25-5.78-1.13c-2.08-1.02-3.94-2.71-5.29-5.14c-0.65-0.33-1.13-0.97-1.23-1.75c-0.04-0.31-0.01-0.61,0.07-0.89 c-0.39-1.16-0.68-2.43-0.87-3.83l-0.07,0.01c-1.24,0.17-2.39-0.7-2.56-1.94c-0.17-1.24,0.7-2.39,1.94-2.56l0.54-0.08 C44.19,104.32,44.18,103.16,44.13,102.06L44.13,102.06z M2.18,58.86C1.01,58.89,0.04,57.98,0,56.81c-0.04-1.17,0.88-2.14,2.05-2.18 l8.7-0.3c1.17-0.04,2.14,0.88,2.18,2.05c0.04,1.17-0.88,2.14-2.05,2.18L2.18,58.86L2.18,58.86z M110.68,50.25 c1.16-0.12,2.2,0.73,2.32,1.89c0.12,1.16-0.73,2.2-1.89,2.32l-8.66,0.91c-1.16,0.12-2.2-0.73-2.32-1.89 c-0.12-1.16,0.73-2.2,1.89-2.32L110.68,50.25L110.68,50.25z M94.91,14.78c0.65-0.97,1.96-1.23,2.93-0.58 c0.97,0.65,1.23,1.96,0.58,2.93l-4.84,7.24c-0.65,0.97-1.96,1.23-2.93,0.58c-0.97-0.65-1.23-1.96-0.58-2.93L94.91,14.78 L94.91,14.78z M57.63,2.06c0.03-1.17,1-2.09,2.16-2.06c1.17,0.03,2.09,1,2.06,2.16l-0.22,8.7c-0.03,1.17-1,2.09-2.16,2.06 c-1.17-0.03-2.09-1-2.06-2.16L57.63,2.06L57.63,2.06z M13.88,15.53c-0.86-0.8-0.9-2.14-0.11-2.99c0.8-0.86,2.14-0.9,2.99-0.11 l6.37,5.94c0.86,0.8,0.9,2.14,0.11,2.99c-0.8,0.86-2.14,0.9-2.99,0.11L13.88,15.53L13.88,15.53z M47.88,96.95l18.49-2.63 c1.59-6.7,5.05-10.73,8.8-15.08c3.08-3.58,6.36-7.4,8.76-13.34c1.76-4.35,2.41-8.43,2.31-12.19c-0.1-3.75-0.96-7.21-2.24-10.34 c-2.3-5.63-6.51-10.11-11.65-13.15c-4.11-2.43-8.8-3.94-13.59-4.37c-4.77-0.44-9.64,0.19-14.13,2.02 c-5.26,2.15-9.99,5.97-13.39,11.72c-2.64,5.12-3.86,10.02-3.9,14.73c-0.04,4.74,1.11,9.33,3.2,13.8c2.13,4.56,4.97,7.8,7.69,10.92 C42.47,83.9,46.48,88.49,47.88,96.95L47.88,96.95z M65.62,99.02l-17.27,2.45c0.05,1.1,0.07,2.25,0.05,3.47l17.05-2.42 C65.47,101.29,65.52,100.12,65.62,99.02L65.62,99.02z M48.49,109.52c0.12,0.92,0.3,1.76,0.53,2.54l16.55-2.04 c0.11-0.86,0.13-1.77,0.05-2.74l0,0l0-0.02l-0.01-0.17L48.49,109.52L48.49,109.52z M51.37,116.36c0.64,0.67,1.35,1.19,2.1,1.55 c1.15,0.56,2.42,0.79,3.67,0.72c1.29-0.08,2.57-0.47,3.74-1.15c1.1-0.64,2.09-1.53,2.88-2.65L51.37,116.36L51.37,116.36z"/></g></svg>');
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
vertical-align: middle;
|
||||
margin-inline-start: 0.5ex;
|
||||
margin-inline-end: 0.5ex;
|
||||
margin-top: -0.2em;
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
display: inline-block;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M270.2 160h35.5c3.4 0 6.1 2.8 6 6.2l-7.5 196c-.1 3.2-2.8 5.8-6 5.8h-20.5c-3.2 0-5.9-2.5-6-5.8l-7.5-196c-.1-3.4 2.6-6.2 6-6.2zM288 388c-15.5 0-28 12.5-28 28s12.5 28 28 28 28-12.5 28-28-12.5-28-28-28zm281.5 52L329.6 24c-18.4-32-64.7-32-83.2 0L6.5 440c-18.4 31.9 4.6 72 41.6 72H528c36.8 0 60-40 41.5-72zM528 480H48c-12.3 0-20-13.3-13.9-24l240-416c6.1-10.6 21.6-10.7 27.7 0l240 416c6.2 10.6-1.5 24-13.8 24z" fill="%23DEAD22" stroke="%23DEAD22"/></svg>');
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
vertical-align: middle;
|
||||
margin-inline-start: 0.3ex;
|
||||
margin-inline-end: 1.1ex;
|
||||
margin-top: -0.1em;
|
||||
}
|
||||
|
||||
.documentation-button {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M256 340c-15.464 0-28 12.536-28 28s12.536 28 28 28 28-12.536 28-28-12.536-28-28-28zm7.67-24h-16c-6.627 0-12-5.373-12-12v-.381c0-70.343 77.44-63.619 77.44-107.408 0-20.016-17.761-40.211-57.44-40.211-29.144 0-44.265 9.649-59.211 28.692-3.908 4.98-11.054 5.995-16.248 2.376l-13.134-9.15c-5.625-3.919-6.86-11.771-2.645-17.177C185.658 133.514 210.842 116 255.67 116c52.32 0 97.44 29.751 97.44 80.211 0 67.414-77.44 63.849-77.44 107.408V304c0 6.627-5.373 12-12 12zM256 40c118.621 0 216 96.075 216 216 0 119.291-96.61 216-216 216-119.244 0-216-96.562-216-216 0-119.203 96.602-216 216-216m0-32C119.043 8 8 119.083 8 256c0 136.997 111.043 248 248 248s248-111.003 248-248C504 119.083 392.957 8 256 8z" fill="%23999999" stroke="%23999999"/></svg>');
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
vertical-align: middle;
|
||||
margin-inline-start: 0.5ex;
|
||||
margin-inline-end: 0.5ex;
|
||||
margin-top: -0.2em;
|
||||
}
|
||||
|
||||
.documentation-button::selection {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.documentation-button:hover {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M433.941 193.941l-51.882-51.882A48 48 0 0 0 348.118 128H320V80c0-26.51-21.49-48-48-48h-66.752C198.643 13.377 180.858 0 160 0s-38.643 13.377-45.248 32H48C21.49 32 0 53.49 0 80v288c0 26.51 21.49 48 48 48h80v48c0 26.51 21.49 48 48 48h224c26.51 0 48-21.49 48-48V227.882a48 48 0 0 0-14.059-33.941zm-22.627 22.627a15.888 15.888 0 0 1 4.195 7.432H352v-63.509a15.88 15.88 0 0 1 7.431 4.195l51.883 51.882zM160 30c9.941 0 18 8.059 18 18s-8.059 18-18 18-18-8.059-18-18 8.059-18 18-18zM48 384c-8.822 0-16-7.178-16-16V80c0-8.822 7.178-16 16-16h66.752c6.605 18.623 24.389 32 45.248 32s38.643-13.377 45.248-32H272c8.822 0 16 7.178 16 16v48H176c-26.51 0-48 21.49-48 48v208H48zm352 96H176c-8.822 0-16-7.178-16-16V176c0-8.822 7.178-16 16-16h144v72c0 13.2 10.8 24 24 24h72v208c0 8.822-7.178 16-16 16z" fill="%23999999" stroke="%23999999"/></svg>');
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
vertical-align: middle;
|
||||
margin-inline-start: 0.5ex;
|
||||
margin-top: -0.2em;
|
||||
}
|
||||
|
||||
.groups{
|
||||
display: flex;
|
||||
border-bottom: 1px solid #EDEEEF;
|
||||
flex: 1 0 100%;
|
||||
}
|
||||
|
||||
.uncategorized {
|
||||
display: flex;
|
||||
border-top: 4px solid #EDEEEF;
|
||||
flex: 1 0 100%;
|
||||
}
|
||||
|
||||
.group-selector {
|
||||
padding: 0 52px 24px 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
color: #999999;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.group-selector__count {
|
||||
margin: 0 8px;
|
||||
border-radius: 8px;
|
||||
background-color: #999;
|
||||
color: #fff;
|
||||
padding: 1px 8px 2px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.group-selector--active {
|
||||
color: #02303A;
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
.group-selector--active .group-selector__count {
|
||||
background-color: #686868;
|
||||
}
|
||||
|
||||
.group-selector--disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.accordion-header {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding-left: 0.5em;
|
||||
padding-right: 0.5em;
|
||||
}
|
||||
|
||||
.stacktrace {
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0;
|
||||
min-width: 1000px;
|
||||
}
|
||||
|
||||
/* Lato (bold, regular) */
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-family: Lato;
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
src: url("https://assets.gradle.com/lato/fonts/lato-semibold/lato-semibold.woff2") format("woff2"),
|
||||
url("https://assets.gradle.com/lato/fonts/lato-semibold/lato-semibold.woff") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-family: Lato;
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
src: url("https://assets.gradle.com/lato/fonts/lato-bold/lato-bold.woff2") format("woff2"),
|
||||
url("https://assets.gradle.com/lato/fonts/lato-bold/lato-bold.woff") format("woff");
|
||||
}
|
||||
|
||||
* {
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: "Lato", "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
body {
|
||||
color: #02303A;
|
||||
background-color: #ffffff;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
|
||||
/* typography */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: #02303A;
|
||||
text-rendering: optimizeLegibility;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
h4, h5, h6 {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
h1 code {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
ul, ol, dl {
|
||||
list-style-position: outside;
|
||||
line-height: 1.6;
|
||||
padding: 0;
|
||||
margin: 0 0 0 20px;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
li {
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #1DA2BD;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease, visibility 0s;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #35c1e4;
|
||||
}
|
||||
|
||||
/* code */
|
||||
code, pre {
|
||||
font-family: Inconsolata, Monaco, "Courier New", monospace;
|
||||
font-style: normal;
|
||||
font-variant-ligatures: normal;
|
||||
font-variant-caps: normal;
|
||||
font-variant-numeric: normal;
|
||||
font-variant-east-asian: normal;
|
||||
font-weight: normal;
|
||||
font-stretch: normal;
|
||||
color: #686868;
|
||||
}
|
||||
|
||||
*:not(pre) > code {
|
||||
letter-spacing: 0;
|
||||
padding: 0.1em 0.5ex;
|
||||
text-rendering: optimizeSpeed;
|
||||
word-spacing: -0.15em;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.8;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.5em;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
pre code {
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
line-height: 1.8;
|
||||
font-size: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a code {
|
||||
color: #1BA8CB;
|
||||
}
|
||||
|
||||
pre.code, pre.programlisting, pre.screen, pre.tt {
|
||||
background-color: #f7f7f8;
|
||||
border-radius: 4px;
|
||||
font-size: 1em;
|
||||
line-height: 1.45;
|
||||
margin-bottom: 1.25em;
|
||||
overflow-x: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
li em, p em {
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
code em, tt em {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
code + .copy-button {
|
||||
margin-inline-start: 0.2ex;
|
||||
}
|
||||
|
||||
.java-exception {
|
||||
font-size: 0.75rem;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.java-exception ul {
|
||||
margin: 0;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.java-exception code {
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.java-exception-part-toggle {
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
padding: 0.1em 0.2em;
|
||||
background: azure;
|
||||
color: #686868;
|
||||
}
|
||||
|
||||
</style>
|
||||
<!-- Inconsolata is used as a default monospace font in the report. -->
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Inconsolata:400,700" />
|
||||
|
||||
<title>Gradle Configuration Cache</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="playground"></div>
|
||||
|
||||
<div class="report" id="report">
|
||||
Loading...
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
function configurationCacheProblems() { return (
|
||||
// begin-report-data
|
||||
{"diagnostics":[{"locations":[{}],"problem":[{"text":"Executing Gradle on JVM versions 16 and lower has been deprecated."}],"severity":"WARNING","problemDetails":[{"text":"This will fail with an error in Gradle 9.0."}],"contextualLabel":"Executing Gradle on JVM versions 16 and lower has been deprecated.","documentationLink":"https://docs.gradle.org/8.14/userguide/upgrading_version_8.html#minimum_daemon_jvm_version","problemId":[{"name":"deprecation","displayName":"Deprecation"},{"name":"executing-gradle-on-jvm-versions-and-lower","displayName":"Executing Gradle on JVM versions 16 and lower has been deprecated."}],"solutions":[[{"text":"Use JVM 17 or greater to execute Gradle. Projects can continue to use older JVM versions via toolchains."}]]},{"problem":[{"text":"No variants exist that would match the request"}],"severity":"ERROR","contextualLabel":"Dependency requires at least JVM runtime version 11. This build uses a Java 8 JVM.","documentationLink":"https://docs.gradle.org/8.14/userguide/variant_model.html#sec:variant-select-errors","problemId":[{"name":"dependency-variant-resolution","displayName":"Variant resolution"},{"name":"no-compatible-variants","displayName":"No variants exist that would match the request"}]}],"problemsReport":{"totalProblemCount":2,"requestedTasks":"","documentationLink":"https://docs.gradle.org/8.14/userguide/reporting_problems.html","documentationLinkCaption":"Problem report","summaries":[]}}
|
||||
// end-report-data
|
||||
);}
|
||||
</script>
|
||||
<script type="text/javascript">
|
||||
!function(n,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports["configuration-cache-report"]=t():n["configuration-cache-report"]=t()}(this,(()=>(({70:function(){void 0===ArrayBuffer.isView&&(ArrayBuffer.isView=function(n){return null!=n&&null!=n.__proto__&&n.__proto__.__proto__===Int8Array.prototype.__proto__}),void 0===Math.imul&&(Math.imul=function(n,t){return(4294901760&n)*(65535&t)+(65535&n)*(0|t)|0}),this["configuration-cache-report"]=function(n){"use strict";var t,r,i,e,u,o,f,s,c,a,h,l,_,v,d,g,w,b,p,m,k,q,y,B,C,x,j,P,I,S,z,T,E,L,N,A,M,F,D,O,R,H,$,G,U,V,Q,Z,Y,W,K,X,J,nn,tn,rn,en,un,on,fn,sn,cn,an,hn,ln,_n,vn,dn,gn,wn,bn,pn,mn,kn,qn,yn,Bn,Cn,xn,jn,Pn,In,Sn,zn,Tn,En,Ln,Nn,An,Mn=Math.imul,Fn=ArrayBuffer.isView;function Dn(n,t){if(!(t>=0))throw _u(ce("Requested element count "+t+" is less than zero."));return function(n,t){if(!(t>=0))throw _u(ce("Requested element count "+t+" is less than zero."));if(0===t)return Ct();if(t>=n.length)return function(n){switch(n.length){case 0:return Ct();case 1:return qr(n[0]);default:return function(n){return Hr(function(n){return new It(n,!1)}(n))}(n)}}(n);if(1===t)return qr(n[0]);var r=0,i=Rr(),e=0,u=n.length;n:for(;e<u;){var o=n[e];if(e=e+1|0,i.d(o),(r=r+1|0)===t)break n}return i}(n,rt(n.length-t|0,0))}function On(n,t,r,i,e,u,o){return t=t===A?", ":t,r=r===A?"":r,i=i===A?"":i,e=e===A?-1:e,u=u===A?"...":u,o=o===A?null:o,function(n,t,r,i,e,u,o,f){r=r===A?", ":r,i=i===A?"":i,e=e===A?"":e,u=u===A?-1:u,o=o===A?"...":o,f=f===A?null:f,t.e(i);var s=0,c=0,a=n.length;n:for(;c<a;){var h=n[c];if(c=c+1|0,(s=s+1|0)>1&&t.e(r),!(u<0||s<=u))break n;Jt(t,h,f)}return u>=0&&s>u&&t.e(o),t.e(e),t}(n,Bi(),t,r,i,e,u,o).toString()}function Rn(n){return n.length-1|0}function Hn(n,t){if(null==t){var r=0,i=n.length-1|0;if(r<=i)do{var e=r;if(r=r+1|0,null==n[e])return e}while(r<=i)}else{var u=0,o=n.length-1|0;if(u<=o)do{var f=u;if(u=u+1|0,le(t,n[f]))return f}while(u<=o)}return-1}function $n(n,t,r,i,e,u,o){return t=t===A?", ":t,r=r===A?"":r,i=i===A?"":i,e=e===A?-1:e,u=u===A?"...":u,o=o===A?null:o,Gn(n,Bi(),t,r,i,e,u,o).toString()}function Gn(n,t,r,i,e,u,o,f){r=r===A?", ":r,i=i===A?"":i,e=e===A?"":e,u=u===A?-1:u,o=o===A?"...":o,f=f===A?null:f,t.e(i);var s=0,c=n.f();n:for(;c.g();){var a=c.h();if((s=s+1|0)>1&&t.e(r),!(u<0||s<=u))break n;Jt(t,a,f)}return u>=0&&s>u&&t.e(o),t.e(e),t}function Un(n){if(n.i())throw xu("List is empty.");return n.j(0)}function Vn(n){return new tt(n)}function Qn(n){if(Ke(n,Di)){var t;switch(n.k()){case 0:t=Ct();break;case 1:t=qr(Ke(n,Fi)?n.j(0):n.f().h());break;default:t=Zn(n)}return t}return xt(Xn(n))}function Zn(n){return Hr(n)}function Yn(n){if(Ke(n,Di)&&n.k()<=1)return Qn(n);var t=Xn(n);return function(n){var t=(n.k()/2|0)-1|0;if(t<0)return br();var r=jt(n),i=0;if(i<=t)do{var e=i;i=i+1|0;var u=n.j(e);n.f4(e,n.j(r)),n.f4(r,u),r=r-1|0}while(e!==t)}(t),t}function Wn(n,t){if(!(t>=0))throw _u(ce("Requested element count "+t+" is less than zero."));return function(n,t){if(!(t>=0))throw _u(ce("Requested element count "+t+" is less than zero."));if(0===t)return Ct();if(Ke(n,Di)){if(t>=n.k())return Qn(n);if(1===t)return qr(function(n){if(Ke(n,Fi))return Un(n);var t=n.f();if(!t.g())throw xu("Collection is empty.");return t.h()}(n))}var r=0,i=Rr(),e=n.f();n:for(;e.g();){var u=e.h();if(i.d(u),(r=r+1|0)===t)break n}return xt(i)}(n,rt(n.k()-t|0,0))}function Kn(n,t){if(!(t>=0))throw _u(ce("Requested element count "+t+" is less than zero."));if(0===t)return Ct();var r=n.k();if(t>=r)return Qn(n);if(1===t)return qr(Jn(n));var i=Rr();if(Ke(n,bi)){var e=r-t|0;if(e<r)do{var u=e;e=e+1|0,i.d(n.j(u))}while(e<r)}else for(var o=n.l(r-t|0);o.g();){var f=o.h();i.d(f)}return i}function Xn(n){return Ke(n,Di)?Zn(n):nt(n,Or())}function Jn(n){if(n.i())throw xu("List is empty.");return n.j(jt(n))}function nt(n,t){for(var r=n.f();r.g();){var i=r.h();t.d(i)}return t}function tt(n){this.n_1=n}function rt(n,t){return n<t?t:n}function it(n,t){return n>t?t:n}function et(n,t){return Kt().q(n,t,-1)}function ut(n,t){return new Ft(n,t)}function ot(n){var t=n.f();if(!t.g())return Ct();var r=t.h();if(!t.g())return qr(r);var i=Or();for(i.d(r);t.g();)i.d(t.h());return i}function ft(n){this.r_1=n}function st(n,t){this.s_1=n,this.t_1=t}function ct(){}function at(n){this.x_1=n,this.w_1=0}function ht(n,t){this.a1_1=n,at.call(this,n),_t().b1(t,this.a1_1.k()),this.w_1=t}function lt(){t=this}function _t(){return null==t&&new lt,t}function vt(){_t(),ct.call(this)}function dt(n){this.h1_1=n}function gt(n,t){return t===n?"(this Map)":Vi(t)}function wt(n,t){var r;n:{for(var i=n.o().f();i.g();){var e=i.h();if(le(e.j1(),t)){r=e;break n}}r=null}return r}function bt(){r=this}function pt(){return null==r&&new bt,r}function mt(n){this.q1_1=n,ct.call(this)}function kt(){pt(),this.n1_1=null,this.o1_1=null}function qt(){i=this}function yt(){return null==i&&new qt,i}function Bt(n){return n.length>0?ou(n):Ct()}function Ct()Line truncated
|
||||
//# sourceMappingURL=configuration-cache-report.js.map
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
@@ -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-8.14-all.zip
|
||||
@@ -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 "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
@@ -0,0 +1,34 @@
|
||||
**/dgph
|
||||
*.mode1v3
|
||||
*.mode2v3
|
||||
*.moved-aside
|
||||
*.pbxuser
|
||||
*.perspectivev3
|
||||
**/*sync/
|
||||
.sconsign.dblite
|
||||
.tags*
|
||||
**/.vagrant/
|
||||
**/DerivedData/
|
||||
Icon?
|
||||
**/Pods/
|
||||
**/.symlinks/
|
||||
profile
|
||||
xcuserdata
|
||||
**/.generated/
|
||||
Flutter/App.framework
|
||||
Flutter/Flutter.framework
|
||||
Flutter/Flutter.podspec
|
||||
Flutter/Generated.xcconfig
|
||||
Flutter/ephemeral/
|
||||
Flutter/app.flx
|
||||
Flutter/app.zip
|
||||
Flutter/flutter_assets/
|
||||
Flutter/flutter_export_environment.sh
|
||||
ServiceDefinitions.json
|
||||
Runner/GeneratedPluginRegistrant.*
|
||||
|
||||
# Exceptions to above rules.
|
||||
!default.mode1v3
|
||||
!default.mode2v3
|
||||
!default.pbxuser
|
||||
!default.perspectivev3
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>App</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>io.flutter.flutter.app</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>App</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
@@ -0,0 +1,620 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
|
||||
remoteInfo = Runner;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */,
|
||||
);
|
||||
path = RunnerTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */,
|
||||
);
|
||||
name = Flutter;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146E51CF9000F007C117D = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9740EEB11CF90186004384FC /* Flutter */,
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146EF1CF9000F007C117D /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146F01CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */,
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
|
||||
97C147021CF9000F007C117D /* Info.plist */,
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
);
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
331C8080294A63A400263BE5 /* RunnerTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
331C807D294A63A400263BE5 /* Sources */,
|
||||
331C807F294A63A400263BE5 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
331C8086294A63A400263BE5 /* PBXTargetDependency */,
|
||||
);
|
||||
name = RunnerTests;
|
||||
productName = RunnerTests;
|
||||
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
9740EEB61CF901F6004384FC /* Run Script */,
|
||||
97C146EA1CF9000F007C117D /* Sources */,
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = Runner;
|
||||
productName = Runner;
|
||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
97C146E61CF9000F007C117D /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1510;
|
||||
ORGANIZATIONNAME = "";
|
||||
TargetAttributes = {
|
||||
331C8080294A63A400263BE5 = {
|
||||
CreatedOnToolsVersion = 14.0;
|
||||
TestTargetID = 97C146ED1CF9000F007C117D;
|
||||
};
|
||||
97C146ED1CF9000F007C117D = {
|
||||
CreatedOnToolsVersion = 7.3.1;
|
||||
LastSwiftMigration = 1100;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
|
||||
compatibilityVersion = "Xcode 9.3";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
97C146ED1CF9000F007C117D /* Runner */,
|
||||
331C8080294A63A400263BE5 /* RunnerTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
331C807F294A63A400263BE5 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EC1CF9000F007C117D /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
|
||||
);
|
||||
name = "Thin Binary";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||
};
|
||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Run Script";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
331C807D294A63A400263BE5 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EA1CF9000F007C117D /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 97C146ED1CF9000F007C117D /* Runner */;
|
||||
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
97C146FB1CF9000F007C117D /* Base */,
|
||||
);
|
||||
name = Main.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
97C147001CF9000F007C117D /* Base */,
|
||||
);
|
||||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
249021D3217E4FDB00AE95B9 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
249021D4217E4FDB00AE95B9 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.voltlink.smartGridIotAppFlutter;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
331C8088294A63A400263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.voltlink.smartGridIotAppFlutter.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
331C8089294A63A400263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.voltlink.smartGridIotAppFlutter.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
331C808A294A63A400263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.voltlink.smartGridIotAppFlutter.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
97C147031CF9000F007C117D /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
97C147041CF9000F007C117D /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
97C147061CF9000F007C117D /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.voltlink.smartGridIotAppFlutter;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
97C147071CF9000F007C117D /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.voltlink.smartGridIotAppFlutter;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
331C8088294A63A400263BE5 /* Debug */,
|
||||
331C8089294A63A400263BE5 /* Release */,
|
||||
331C808A294A63A400263BE5 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
97C147031CF9000F007C117D /* Debug */,
|
||||
97C147041CF9000F007C117D /* Release */,
|
||||
249021D3217E4FDB00AE95B9 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
97C147061CF9000F007C117D /* Debug */,
|
||||
97C147071CF9000F007C117D /* Release */,
|
||||
249021D4217E4FDB00AE95B9 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PreviewsEnabled</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,101 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1510"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "331C8080294A63A400263BE5"
|
||||
BuildableName = "RunnerTests.xctest"
|
||||
BlueprintName = "RunnerTests"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
enableGPUValidationMode = "1"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Profile"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PreviewsEnabled</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,16 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "83.5x83.5",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "1024x1024",
|
||||
"idiom" : "ios-marketing",
|
||||
"filename" : "Icon-App-1024x1024@1x.png",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage@3x.png",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
@@ -0,0 +1,5 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<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">
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||
</imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchImage" width="168" height="185"/>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Flutter View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
@@ -0,0 +1,70 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Smart Grid Iot App Flutter</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>smart_grid_iot_app_flutter</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>UIWindowScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>flutter</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1 @@
|
||||
#import "GeneratedPluginRegistrant.h"
|
||||
@@ -0,0 +1,6 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
class SceneDelegate: FlutterSceneDelegate {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import XCTest
|
||||
|
||||
class RunnerTests: XCTestCase {
|
||||
|
||||
func testExample() {
|
||||
// If you add code to the Runner application, consider adding tests here.
|
||||
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/app_keys.dart';
|
||||
import '../core/theme/app_theme.dart';
|
||||
import '../core/theme/app_typography.dart';
|
||||
import 'router.dart';
|
||||
|
||||
class VoltLinkApp extends ConsumerWidget {
|
||||
const VoltLinkApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final router = ref.watch(routerProvider);
|
||||
|
||||
return MaterialApp.router(
|
||||
title: 'VoltLink',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.build(),
|
||||
routerConfig: router,
|
||||
scaffoldMessengerKey: AppKeys.scaffoldMessenger,
|
||||
locale: const Locale('zh', 'CN'),
|
||||
supportedLocales: const [Locale('zh', 'CN'), Locale('en', 'US')],
|
||||
localizationsDelegates: const [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
// 对应源 `page` 上的全局字体设置。
|
||||
builder: (context, child) => DefaultTextStyle(
|
||||
style: appDefaultTextStyle,
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../core/app_keys.dart';
|
||||
import '../features/auth/application/session_controller.dart';
|
||||
import '../features/auth/presentation/login_page.dart';
|
||||
import '../features/auth/presentation/register_page.dart';
|
||||
import '../features/notice/presentation/notice_center_page.dart';
|
||||
import '../features/order/presentation/order_detail_page.dart';
|
||||
import 'tabs_page.dart';
|
||||
|
||||
/// 会话语义变化时驱动 go_router 重新执行 `redirect`。
|
||||
class _SessionRefresh extends ChangeNotifier {
|
||||
void bump() => notifyListeners();
|
||||
}
|
||||
|
||||
/// 路由表,对应源 `pages.json` 的 5 个页面。
|
||||
///
|
||||
/// 除登录/注册外,其余页面统一使用主题里的 `CupertinoPageTransitionsBuilder`
|
||||
/// (新页面从右侧滑入),与源 `PageTransition.vue` 的表现一致。
|
||||
final routerProvider = Provider<GoRouter>((ref) {
|
||||
final refresh = _SessionRefresh();
|
||||
ref.listen(sessionProvider, (_, _) => refresh.bump());
|
||||
ref.onDispose(refresh.dispose);
|
||||
|
||||
return GoRouter(
|
||||
navigatorKey: AppKeys.rootNavigator,
|
||||
initialLocation: '/login',
|
||||
refreshListenable: refresh,
|
||||
redirect: (context, state) {
|
||||
final loggedIn = ref.read(sessionProvider).isLoggedIn;
|
||||
final atAuth =
|
||||
state.matchedLocation == '/login' || state.matchedLocation == '/register';
|
||||
if (!loggedIn) return atAuth ? null : '/login';
|
||||
if (atAuth) return '/tabs';
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
GoRoute(path: '/login', builder: (context, state) => const LoginPage()),
|
||||
GoRoute(
|
||||
path: '/register',
|
||||
builder: (context, state) => const RegisterPage(),
|
||||
),
|
||||
GoRoute(path: '/tabs', builder: (context, state) => const TabsPage()),
|
||||
|
||||
// 消息中心:顶栏铃铛入口
|
||||
GoRoute(
|
||||
path: '/notice',
|
||||
builder: (context, state) => const NoticeCenterPage(),
|
||||
),
|
||||
|
||||
// 订单详情:source 为 repair|install|maintenance|survey
|
||||
GoRoute(
|
||||
path: '/order/:source/:id',
|
||||
builder: (context, state) => OrderDetailPage(
|
||||
source: state.pathParameters['source']!,
|
||||
id: int.parse(state.pathParameters['id']!),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// 底部导航的三个 Tab,对应 `components/CustomTabBar.vue` 的 3 个入口。
|
||||
enum AppTab { order, earnings, profile }
|
||||
|
||||
extension AppTabKey on AppTab {
|
||||
/// 与 `XBottomNav.items` 中的 key 一致。
|
||||
String get key => switch (this) {
|
||||
AppTab.order => 'order',
|
||||
AppTab.earnings => 'earnings',
|
||||
AppTab.profile => 'profile',
|
||||
};
|
||||
}
|
||||
|
||||
/// 当前 Tab。源里每个页面各自挂 `CustomTabBar` 并用 `redirectTo` 切换,
|
||||
/// Flutter 端改为单容器 + [IndexedStack],避免页面重建、保留各自滚动位置。
|
||||
class TabIndexController extends Notifier<AppTab> {
|
||||
@override
|
||||
AppTab build() => AppTab.order;
|
||||
|
||||
void select(AppTab tab) => state = tab;
|
||||
|
||||
void selectByKey(String key) {
|
||||
state = AppTab.values.firstWhere((t) => t.key == key, orElse: () => state);
|
||||
}
|
||||
}
|
||||
|
||||
final tabIndexProvider = NotifierProvider<TabIndexController, AppTab>(
|
||||
TabIndexController.new,
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/theme/app_colors.dart';
|
||||
import '../features/earnings/presentation/earnings_tab.dart';
|
||||
import '../features/order/presentation/order_tab.dart';
|
||||
import '../features/profile/presentation/profile_tab.dart';
|
||||
import '../shared/widgets/x_bottom_nav.dart';
|
||||
import 'tab_index.dart';
|
||||
|
||||
/// 3 个 Tab 的常驻容器。源项目每个页面各自挂 `CustomTabBar`,
|
||||
/// 这里统一收口到容器层,用 [IndexedStack] 保留各页内部状态。
|
||||
class TabsPage extends ConsumerWidget {
|
||||
const TabsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final current = ref.watch(tabIndexProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.bgPage,
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: IndexedStack(
|
||||
index: current.index,
|
||||
children: const [OrderTab(), EarningsTab(), ProfileTab()],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: XBottomNav(
|
||||
current: current.key,
|
||||
onChanged: (key) =>
|
||||
ref.read(tabIndexProvider.notifier).selectByKey(key),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 全局 Key:供非 Widget 层(拦截器、工具函数)访问 Navigator / ScaffoldMessenger。
|
||||
abstract final class AppKeys {
|
||||
static final rootNavigator = GlobalKey<NavigatorState>();
|
||||
static final scaffoldMessenger = GlobalKey<ScaffoldMessengerState>();
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/// 运行期配置。
|
||||
///
|
||||
/// 与 uni-app 版 `utils/request.ts` 的地址策略一致:App 端直连后端。
|
||||
/// 真机联调时用 `--dart-define=API_BASE_URL=http://<局域网IP>:7001/api` 覆盖默认值。
|
||||
///
|
||||
/// 登录页「服务端设置」(仅调试版可见)保存的 IP+端口优先级更高,
|
||||
/// 解析顺序见 [resolveBaseUrl]。
|
||||
abstract final class Env {
|
||||
/// 编译期默认地址,作为兜底方案。
|
||||
static const String apiBaseUrl = String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: 'http://192.168.3.29:7001/api',
|
||||
);
|
||||
|
||||
static final Uri _default = Uri.parse(apiBaseUrl);
|
||||
|
||||
/// 默认主机,取自 [apiBaseUrl]。
|
||||
static String get defaultHost => _default.host;
|
||||
|
||||
/// 默认端口,取自 [apiBaseUrl],未显式指定时回落 7007。
|
||||
static int get defaultPort => _default.hasPort ? _default.port : 7001;
|
||||
|
||||
/// 由 IP + 端口拼接 Base URL;协议与路径沿用 [apiBaseUrl],
|
||||
/// 这样 `--dart-define` 传入自定义路径时也能保持一致。
|
||||
static String buildBaseUrl(String host, String port) {
|
||||
final h = host.trim();
|
||||
final p = port.trim();
|
||||
final resolvedPort = p.isEmpty ? '$defaultPort' : p;
|
||||
return '${_default.scheme}://$h:$resolvedPort${_default.path}';
|
||||
}
|
||||
|
||||
/// 当前生效的 Base URL:登录页保存值优先,其次编译期默认值。
|
||||
static String resolveBaseUrl({String host = '', String port = ''}) =>
|
||||
host.trim().isEmpty ? apiBaseUrl : buildBaseUrl(host, port);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../storage/token_storage.dart';
|
||||
import 'api_response.dart';
|
||||
|
||||
/// 统一请求封装,等价于 uni-app 版 `utils/request.ts`:
|
||||
/// - 自动注入 `Authorization: Bearer <token>`
|
||||
/// - 解析 `{ code, message, data }`,非 200 也照常返回给调用方判断
|
||||
/// - 401 直接判定会话失效并广播给会话层,由会话层清空本地登录态回到登录页。
|
||||
///
|
||||
/// 注意:电工端后端未提供 refresh 接口(与管理端 `auth.controller` 不同),
|
||||
/// 因此这里不做静默续期,过期即重新登录。
|
||||
class ApiClient {
|
||||
ApiClient({required TokenStorage storage, required String baseUrl, Dio? dio})
|
||||
: _storage = storage,
|
||||
_dio =
|
||||
dio ??
|
||||
Dio(
|
||||
BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
sendTimeout: const Duration(seconds: 30),
|
||||
// 自行判断业务 code,不因 HTTP 状态码抛异常
|
||||
validateStatus: (_) => true,
|
||||
),
|
||||
);
|
||||
|
||||
final TokenStorage _storage;
|
||||
final Dio _dio;
|
||||
|
||||
final _unauthorizedController = StreamController<void>.broadcast();
|
||||
|
||||
/// 会话失效事件:401 时触发,由会话层订阅后跳转登录页。
|
||||
Stream<void> get onUnauthorized => _unauthorizedController.stream;
|
||||
|
||||
Future<ApiResponse<T>> get<T>(
|
||||
String url, {
|
||||
Map<String, dynamic>? query,
|
||||
T Function(dynamic data)? parse,
|
||||
bool needAuth = true,
|
||||
}) => _send<T>(
|
||||
'GET',
|
||||
url,
|
||||
query: _compact(query),
|
||||
parse: parse,
|
||||
needAuth: needAuth,
|
||||
);
|
||||
|
||||
Future<ApiResponse<T>> post<T>(
|
||||
String url, {
|
||||
Object? body,
|
||||
T Function(dynamic data)? parse,
|
||||
bool needAuth = true,
|
||||
}) => _send<T>('POST', url, body: body, parse: parse, needAuth: needAuth);
|
||||
|
||||
Future<ApiResponse<T>> put<T>(
|
||||
String url, {
|
||||
Object? body,
|
||||
T Function(dynamic data)? parse,
|
||||
bool needAuth = true,
|
||||
}) => _send<T>('PUT', url, body: body, parse: parse, needAuth: needAuth);
|
||||
|
||||
void dispose() => _unauthorizedController.close();
|
||||
|
||||
// ---- 内部实现 ----
|
||||
|
||||
Future<ApiResponse<T>> _send<T>(
|
||||
String method,
|
||||
String url, {
|
||||
Map<String, dynamic>? query,
|
||||
Object? body,
|
||||
T Function(dynamic data)? parse,
|
||||
required bool needAuth,
|
||||
}) async {
|
||||
late Response<dynamic> res;
|
||||
try {
|
||||
res = await _dio.request<dynamic>(
|
||||
url,
|
||||
data: body,
|
||||
queryParameters: query,
|
||||
options: Options(
|
||||
method: method,
|
||||
headers: needAuth ? _authHeader() : null,
|
||||
),
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw ApiException(e.message ?? '网络请求失败');
|
||||
}
|
||||
|
||||
final response = ApiResponse<T>.fromJson(_asMap(res.data), parse);
|
||||
|
||||
if (response.code == 401) {
|
||||
_unauthorizedController.add(null);
|
||||
throw const ApiException('登录已失效,请重新登录', code: 401);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _authHeader() {
|
||||
final token = _storage.accessToken;
|
||||
return token.isEmpty ? const {} : {'Authorization': 'Bearer $token'};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _asMap(dynamic data) {
|
||||
if (data is Map) return data.cast<String, dynamic>();
|
||||
if (data is String && data.isNotEmpty) {
|
||||
try {
|
||||
final decoded = jsonDecode(data);
|
||||
if (decoded is Map) return decoded.cast<String, dynamic>();
|
||||
} catch (_) {}
|
||||
}
|
||||
return const {'code': -1, 'message': '响应解析失败'};
|
||||
}
|
||||
|
||||
/// 去掉 null / 空串查询条件。
|
||||
/// uni-app 会把 undefined 序列化成空串(如 `status=`),后端对空串查询条件会 500,
|
||||
/// 因此这里同样只在有值时才拼接参数。
|
||||
static Map<String, dynamic>? _compact(Map<String, dynamic>? src) {
|
||||
if (src == null || src.isEmpty) return null;
|
||||
final out = <String, dynamic>{};
|
||||
src.forEach((k, v) {
|
||||
if (v == null) return;
|
||||
if (v is String && v.isEmpty) return;
|
||||
out[k] = v;
|
||||
});
|
||||
return out.isEmpty ? null : out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/// 后端统一响应体:`{ code, message, data }`。
|
||||
class ApiResponse<T> {
|
||||
const ApiResponse({required this.code, required this.message, this.data});
|
||||
|
||||
final int code;
|
||||
final String message;
|
||||
final T? data;
|
||||
|
||||
bool get isOk => code == 200;
|
||||
|
||||
factory ApiResponse.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
T Function(dynamic data)? parse,
|
||||
) {
|
||||
final raw = json['data'];
|
||||
return ApiResponse<T>(
|
||||
code: (json['code'] as num?)?.toInt() ?? -1,
|
||||
message: (json['message'] as String?) ?? '',
|
||||
data: (raw == null || parse == null) ? null : parse(raw),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 网络/协议层异常。调用方捕获后用 `message` 提示用户。
|
||||
class ApiException implements Exception {
|
||||
const ApiException(this.message, {this.code});
|
||||
|
||||
final String message;
|
||||
final int? code;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../features/auth/application/session_controller.dart';
|
||||
import '../features/notice/data/notice_api.dart';
|
||||
import 'env.dart';
|
||||
import 'network/api_client.dart';
|
||||
import 'network/api_response.dart';
|
||||
import 'storage/token_storage.dart';
|
||||
|
||||
/// 在 `main()` 中读取 SharedPreferences 后通过 override 注入。
|
||||
final tokenStorageProvider = Provider<TokenStorage>(
|
||||
(ref) => throw UnimplementedError('tokenStorageProvider 必须在 ProviderScope 中 override'),
|
||||
);
|
||||
|
||||
/// 登录页「服务端设置」保存的主机与端口;空串表示未覆盖,走编译期默认值。
|
||||
typedef ServerAddress = ({String host, String port});
|
||||
|
||||
/// 服务端地址状态。登录页保存/重置后通知 [apiClientProvider] 重建客户端,
|
||||
/// 因此无需重启 App 即可切换后端。
|
||||
class ServerAddressNotifier extends Notifier<ServerAddress> {
|
||||
@override
|
||||
ServerAddress build() {
|
||||
final storage = ref.watch(tokenStorageProvider);
|
||||
return (host: storage.serverHost, port: storage.serverPort);
|
||||
}
|
||||
|
||||
Future<void> save(String host, String port) async {
|
||||
await ref.read(tokenStorageProvider).setServerAddress(host, port);
|
||||
state = (host: host, port: port);
|
||||
}
|
||||
|
||||
Future<void> reset() async {
|
||||
await ref.read(tokenStorageProvider).clearServerAddress();
|
||||
state = (host: '', port: '');
|
||||
}
|
||||
}
|
||||
|
||||
final serverAddressProvider =
|
||||
NotifierProvider<ServerAddressNotifier, ServerAddress>(
|
||||
ServerAddressNotifier.new,
|
||||
);
|
||||
|
||||
/// 当前生效的 API Base URL。
|
||||
final apiBaseUrlProvider = Provider<String>(
|
||||
(ref) => Env.resolveBaseUrl(
|
||||
host: ref.watch(serverAddressProvider).host,
|
||||
port: ref.watch(serverAddressProvider).port,
|
||||
),
|
||||
);
|
||||
|
||||
final apiClientProvider = Provider<ApiClient>((ref) {
|
||||
final client = ApiClient(
|
||||
storage: ref.watch(tokenStorageProvider),
|
||||
baseUrl: ref.watch(apiBaseUrlProvider),
|
||||
);
|
||||
ref.onDispose(client.dispose);
|
||||
return client;
|
||||
});
|
||||
|
||||
/// 站内消息中心接口。
|
||||
final noticeApiProvider = Provider<NoticeApi>(
|
||||
(ref) => NoticeApi(ref.watch(apiClientProvider)),
|
||||
);
|
||||
|
||||
/// 未读消息数。登录态下每 60s 轮询一次,登出立即停止并归零;
|
||||
/// 三处顶栏铃铛与消息中心页共用,标记已读后调用 [noticeUnreadCountProvider]
|
||||
/// 的 `refresh()` 立即刷新。
|
||||
final noticeUnreadCountProvider =
|
||||
NotifierProvider<NoticeUnreadCountNotifier, int>(
|
||||
NoticeUnreadCountNotifier.new,
|
||||
);
|
||||
|
||||
class NoticeUnreadCountNotifier extends Notifier<int> {
|
||||
static const _interval = Duration(seconds: 60);
|
||||
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
int build() {
|
||||
// 登录态变化时重建:先停掉上一轮轮询,避免重复计时器。
|
||||
_stop();
|
||||
|
||||
final loggedIn = ref.watch(sessionProvider.select((s) => s.isLoggedIn));
|
||||
if (!loggedIn) return 0;
|
||||
|
||||
ref.onDispose(_stop);
|
||||
_timer = Timer.periodic(_interval, (_) => unawaited(refresh()));
|
||||
unawaited(refresh());
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// 立即拉取一次未读数(进入消息中心 / 单条或全部已读后调用)。
|
||||
Future<void> refresh() async {
|
||||
try {
|
||||
final res = await ref.read(noticeApiProvider).unreadCount();
|
||||
// 请求期间若已登出,丢弃结果,避免残留红点。
|
||||
if (!ref.read(sessionProvider).isLoggedIn) return;
|
||||
if (res.isOk && res.data != null) state = res.data!;
|
||||
} on ApiException {
|
||||
// 轮询失败保持上一次的值,不打断 UI。
|
||||
}
|
||||
}
|
||||
|
||||
void _stop() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 本地持久化。键名与 uni-app 版 `uni.getStorageSync` 使用的键保持一致,
|
||||
/// 便于两端在真机上迁移/对拍时理解同一份数据。
|
||||
class TokenStorage {
|
||||
TokenStorage(this._prefs);
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
static const _kAccessToken = 'accessToken';
|
||||
static const _kUserInfo = 'userInfo';
|
||||
static const _kServerHost = 'serverHost';
|
||||
static const _kServerPort = 'serverPort';
|
||||
|
||||
static Future<TokenStorage> open() async =>
|
||||
TokenStorage(await SharedPreferences.getInstance());
|
||||
|
||||
String get accessToken => _prefs.getString(_kAccessToken) ?? '';
|
||||
|
||||
Map<String, dynamic>? get userInfo {
|
||||
final raw = _prefs.getString(_kUserInfo);
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
try {
|
||||
return jsonDecode(raw) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setAccessToken(String token) =>
|
||||
_prefs.setString(_kAccessToken, token);
|
||||
|
||||
Future<void> setUserInfo(Map<String, dynamic> info) =>
|
||||
_prefs.setString(_kUserInfo, jsonEncode(info));
|
||||
|
||||
/// 登录页「服务端设置」保存的 IP 与端口,空串表示未覆盖、走编译期默认值。
|
||||
String get serverHost => _prefs.getString(_kServerHost) ?? '';
|
||||
|
||||
String get serverPort => _prefs.getString(_kServerPort) ?? '';
|
||||
|
||||
Future<void> setServerAddress(String host, String port) async {
|
||||
await _prefs.setString(_kServerHost, host);
|
||||
await _prefs.setString(_kServerPort, port);
|
||||
}
|
||||
|
||||
Future<void> clearServerAddress() async {
|
||||
await _prefs.remove(_kServerHost);
|
||||
await _prefs.remove(_kServerPort);
|
||||
}
|
||||
|
||||
Future<void> clearSession() async {
|
||||
await _prefs.remove(_kAccessToken);
|
||||
await _prefs.remove(_kUserInfo);
|
||||
}
|
||||
|
||||
bool get hasSession => accessToken.isNotEmpty;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 设计令牌 - 色彩。
|
||||
/// 逐项对应 uni-app 版 `src/uni.scss` 中的 `$color-*` 变量。
|
||||
abstract final class AppColors {
|
||||
// Primary
|
||||
/// 品牌主色(源 `$primary-container`)。源页面绝大多数蓝色元素用它。
|
||||
static const primary = Color(0xFF0052FF);
|
||||
|
||||
/// 深一档的品牌色(源 `$primary`),用于余额卡渐变起始色。
|
||||
static const primaryDeep = Color(0xFF003EC7);
|
||||
static const onPrimary = Color(0xFFFFFFFF);
|
||||
static const onPrimaryContainer = Color(0xFFDFE3FF);
|
||||
static const primaryFixed = Color(0xFFDDE1FF);
|
||||
static const primaryFixedDim = Color(0xFFB7C4FF);
|
||||
static const onPrimaryFixed = Color(0xFF001452);
|
||||
static const onPrimaryFixedVariant = Color(0xFF0038B6);
|
||||
|
||||
// Secondary
|
||||
static const secondary = Color(0xFF4C4ACA);
|
||||
static const secondaryContainer = Color(0xFF6664E4);
|
||||
static const onSecondary = Color(0xFFFFFFFF);
|
||||
static const onSecondaryContainer = Color(0xFFFFFBFF);
|
||||
static const secondaryFixed = Color(0xFFE2DFFF);
|
||||
static const secondaryFixedDim = Color(0xFFC2C1FF);
|
||||
static const onSecondaryFixed = Color(0xFF0C006A);
|
||||
static const onSecondaryFixedVariant = Color(0xFF3631B4);
|
||||
|
||||
// Tertiary
|
||||
static const tertiary = Color(0xFF952200);
|
||||
static const tertiaryContainer = Color(0xFFBF3003);
|
||||
static const onTertiary = Color(0xFFFFFFFF);
|
||||
static const onTertiaryContainer = Color(0xFFFFDDD5);
|
||||
static const tertiaryFixed = Color(0xFFFFDBD2);
|
||||
static const tertiaryFixedDim = Color(0xFFFFB4A1);
|
||||
static const onTertiaryFixed = Color(0xFF3C0800);
|
||||
static const onTertiaryFixedVariant = Color(0xFF891E00);
|
||||
|
||||
// Error
|
||||
static const error = Color(0xFFBA1A1A);
|
||||
static const errorContainer = Color(0xFFFFDAD6);
|
||||
static const onError = Color(0xFFFFFFFF);
|
||||
static const onErrorContainer = Color(0xFF93000A);
|
||||
|
||||
// Surface
|
||||
static const surface = Color(0xFFFAF9FE);
|
||||
static const surfaceBright = Color(0xFFFAF9FE);
|
||||
static const surfaceDim = Color(0xFFDAD9DF);
|
||||
static const surfaceContainerLowest = Color(0xFFFFFFFF);
|
||||
static const surfaceContainerLow = Color(0xFFF4F3F8);
|
||||
static const surfaceContainer = Color(0xFFEEEDF3);
|
||||
static const surfaceContainerHigh = Color(0xFFE9E7ED);
|
||||
static const surfaceContainerHighest = Color(0xFFE3E2E7);
|
||||
static const surfaceVariant = Color(0xFFE3E2E7);
|
||||
|
||||
// Background
|
||||
static const bgPage = Color(0xFFFAF9FE);
|
||||
static const background = Color(0xFFFAF9FE);
|
||||
|
||||
// Text
|
||||
static const onSurface = Color(0xFF1A1B1F);
|
||||
static const onSurfaceVariant = Color(0xFF434656);
|
||||
static const textMuted = Color(0xFF737688);
|
||||
static const textPlaceholder = Color(0xFFC3C5D9);
|
||||
|
||||
// Outline
|
||||
static const outline = Color(0xFF737688);
|
||||
static const outlineVariant = Color(0xFFC3C5D9);
|
||||
static const borderRule = Color(0xFFC3C5D9);
|
||||
|
||||
/// `rgba($outline-variant, 0.2)` —— 各页面 `.ios-card` / 分隔线用的浅描边。
|
||||
static const borderSoft = Color(0x33C3C5D9);
|
||||
|
||||
/// `.ios-blur` / `.custom-tab-bar` 的毛玻璃底色 rgba(250,249,254,0.85)
|
||||
static const headerGlass = Color(0xD9FAF9FE);
|
||||
|
||||
// Inverse
|
||||
static const inverseSurface = Color(0xFF2F3034);
|
||||
static const inverseOnSurface = Color(0xFFF1F0F5);
|
||||
static const inversePrimary = Color(0xFFB7C4FF);
|
||||
|
||||
// Accent
|
||||
static const accentSubtle = Color(0x140052FF);
|
||||
|
||||
// Status
|
||||
static const success = Color(0xFF34C759);
|
||||
static const star = Color(0xFFFF9500);
|
||||
static const statusWarn = Color(0xFFE0852B);
|
||||
static const statusError = Color(0xFFDC2626);
|
||||
|
||||
// 底部导航毛玻璃底:rgba(250,249,254,0.95)
|
||||
static const navGlass = Color(0xF2FAF9FE);
|
||||
}
|
||||
|
||||
/// chip 语义样式:背景色 + 前景色,对应各页面 `.chip-*` / 徽章配色。
|
||||
enum ChipTone { primary, secondary, urgent, warning, success, muted }
|
||||
|
||||
extension ChipToneStyle on ChipTone {
|
||||
Color get foreground => switch (this) {
|
||||
ChipTone.primary => AppColors.primary,
|
||||
ChipTone.secondary => AppColors.secondary,
|
||||
ChipTone.urgent => AppColors.tertiaryContainer,
|
||||
ChipTone.warning => AppColors.statusWarn,
|
||||
ChipTone.success => AppColors.success,
|
||||
ChipTone.muted => AppColors.onSurfaceVariant,
|
||||
};
|
||||
|
||||
Color get background => switch (this) {
|
||||
ChipTone.primary => AppColors.accentSubtle,
|
||||
ChipTone.secondary => const Color(0x144C4ACA),
|
||||
ChipTone.urgent => const Color(0x14BF3003),
|
||||
ChipTone.warning => const Color(0x14E0852B),
|
||||
ChipTone.success => const Color(0x1434C759),
|
||||
ChipTone.muted => AppColors.surfaceContainer,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:flutter/painting.dart';
|
||||
|
||||
/// 设计令牌 - 间距与圆角。对应 `uni.scss` 的 `$spacing-*` / `$rounded-*`。
|
||||
abstract final class AppSpacing {
|
||||
static const double base = 4;
|
||||
static const double xs = 8;
|
||||
static const double sm = 12;
|
||||
static const double md = 16;
|
||||
static const double lg = 24;
|
||||
static const double xl = 40;
|
||||
|
||||
/// `$spacing-margin-page` / `$spacing-gutter-grid` = 1rem
|
||||
static const double gutter = 16;
|
||||
}
|
||||
|
||||
abstract final class AppRadius {
|
||||
/// `$rounded-sm` = 0.25rem
|
||||
static const double sm = 4;
|
||||
|
||||
/// `$rounded-md` = 0.5rem
|
||||
static const double regular = 8;
|
||||
|
||||
/// `$rounded-lg` = 0.75rem
|
||||
static const double lg = 12;
|
||||
|
||||
/// `$rounded-xl` = 1rem
|
||||
static const double xl = 16;
|
||||
|
||||
/// 订单区 / 底部弹窗的 24px 大圆角
|
||||
static const double xxl = 24;
|
||||
|
||||
/// `$rounded-full`
|
||||
static const double full = 9999;
|
||||
}
|
||||
|
||||
abstract final class AppShadows {
|
||||
/// 全局 `.ios-card`:0 1px 3px rgba(0,0,0,0.04)
|
||||
static const card = [
|
||||
BoxShadow(color: Color(0x0A000000), blurRadius: 3, offset: Offset(0, 1)),
|
||||
];
|
||||
|
||||
/// 订单卡片:0 2px 8px rgba(0,0,0,0.04)
|
||||
static const orderCard = [
|
||||
BoxShadow(color: Color(0x0A000000), blurRadius: 8, offset: Offset(0, 2)),
|
||||
];
|
||||
|
||||
/// 顶栏下边框阴影:0 -1px 3px rgba(0,0,0,0.05)
|
||||
static const topBar = [
|
||||
BoxShadow(color: Color(0x0D000000), blurRadius: 3, offset: Offset(0, 1)),
|
||||
];
|
||||
|
||||
/// `@mixin ios-shadow`:0 2px 8px rgba(0,0,0,0.04)
|
||||
static const soft = [
|
||||
BoxShadow(color: Color(0x0A000000), blurRadius: 8, offset: Offset(0, 2)),
|
||||
];
|
||||
|
||||
/// 底部导航条:向上投影
|
||||
static const bottomNav = [
|
||||
BoxShadow(color: Color(0x14000000), blurRadius: 24, offset: Offset(0, -8)),
|
||||
];
|
||||
|
||||
/// 品牌主按钮发光:0 8px 20px rgba(0,82,255,0.28)
|
||||
static const primaryButton = [
|
||||
BoxShadow(color: Color(0x470052FF), blurRadius: 20, offset: Offset(0, 8)),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'app_colors.dart';
|
||||
|
||||
abstract final class AppTheme {
|
||||
/// 全平台统一使用 iOS 风格「新页面从右侧滑入」转场,与 uni-app 版
|
||||
/// `components/PageTransition.vue` 的表现对齐。
|
||||
static const _transitions = PageTransitionsTheme(
|
||||
builders: {
|
||||
TargetPlatform.android: CupertinoPageTransitionsBuilder(),
|
||||
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
|
||||
TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),
|
||||
TargetPlatform.windows: CupertinoPageTransitionsBuilder(),
|
||||
TargetPlatform.linux: CupertinoPageTransitionsBuilder(),
|
||||
TargetPlatform.fuchsia: CupertinoPageTransitionsBuilder(),
|
||||
},
|
||||
);
|
||||
|
||||
static ThemeData build() {
|
||||
final base = ThemeData.light(useMaterial3: true);
|
||||
|
||||
return base.copyWith(
|
||||
scaffoldBackgroundColor: AppColors.bgPage,
|
||||
pageTransitionsTheme: _transitions,
|
||||
colorScheme: base.colorScheme.copyWith(
|
||||
primary: AppColors.primary,
|
||||
onPrimary: AppColors.onPrimary,
|
||||
secondary: AppColors.secondary,
|
||||
onSecondary: AppColors.onSecondary,
|
||||
tertiary: AppColors.tertiary,
|
||||
onTertiary: AppColors.onTertiary,
|
||||
error: AppColors.error,
|
||||
onError: AppColors.onError,
|
||||
surface: AppColors.surfaceContainerLowest,
|
||||
onSurface: AppColors.onSurface,
|
||||
outline: AppColors.outline,
|
||||
outlineVariant: AppColors.outlineVariant,
|
||||
),
|
||||
textTheme: base.textTheme.apply(
|
||||
bodyColor: AppColors.onSurface,
|
||||
displayColor: AppColors.onSurface,
|
||||
),
|
||||
dividerTheme: const DividerThemeData(
|
||||
color: AppColors.borderRule,
|
||||
thickness: 0.5,
|
||||
space: 1,
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: AppColors.surface,
|
||||
foregroundColor: AppColors.onSurface,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
centerTitle: true,
|
||||
systemOverlayStyle: SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
statusBarBrightness: Brightness.light,
|
||||
),
|
||||
titleTextStyle: TextStyle(
|
||||
fontSize: 17,
|
||||
height: 24 / 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: AppColors.surfaceContainerLow,
|
||||
hintStyle: const TextStyle(color: AppColors.textPlaceholder, fontSize: 17),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.primary, width: 1.5),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.error),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.error, width: 1.5),
|
||||
),
|
||||
),
|
||||
splashFactory: InkSparkle.splashFactory,
|
||||
tooltipTheme: const TooltipThemeData(waitDuration: Duration(milliseconds: 400)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'app_colors.dart';
|
||||
|
||||
/// 设计令牌 - 字号 / 行高 / 字重。
|
||||
/// 对应 `uni.scss` 的 `$font-*` 与 `global.scss` 的 `.font-*` 工具类,
|
||||
/// 行高统一换算成 Flutter 的 `height`(行高 ÷ 字号)。
|
||||
abstract final class AppTypography {
|
||||
static const _sansFallback = <String>[
|
||||
'Inter',
|
||||
'PingFang SC',
|
||||
'Microsoft YaHei',
|
||||
'Noto Sans CJK SC',
|
||||
];
|
||||
|
||||
static const _monoFallback = <String>[
|
||||
'JetBrains Mono',
|
||||
'SF Mono',
|
||||
'Menlo',
|
||||
'Consolas',
|
||||
];
|
||||
|
||||
/// 12 / 16 / w500 / ls 0.02em — `.font-label-caps`
|
||||
static const labelCaps = TextStyle(
|
||||
fontSize: 12,
|
||||
height: 16 / 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.24,
|
||||
fontFamilyFallback: _sansFallback,
|
||||
);
|
||||
|
||||
/// 13 / 18 / w500 — 卡片内的次要信息行
|
||||
static const bodySm = TextStyle(
|
||||
fontSize: 13,
|
||||
height: 18 / 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontFamilyFallback: _sansFallback,
|
||||
);
|
||||
|
||||
/// 15 / 20 / w400 — `.font-body-sm`
|
||||
static const bodyMd = TextStyle(
|
||||
fontSize: 15,
|
||||
height: 20 / 15,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontFamilyFallback: _sansFallback,
|
||||
);
|
||||
|
||||
/// 17 / 22 / w400 / ls -0.01em — `.font-body-lg`
|
||||
static const bodyLg = TextStyle(
|
||||
fontSize: 17,
|
||||
height: 22 / 17,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: -0.17,
|
||||
fontFamilyFallback: _sansFallback,
|
||||
);
|
||||
|
||||
/// 20 / 25 / w600 / ls -0.01em — `.font-headline-md`
|
||||
static const headlineMd = TextStyle(
|
||||
fontSize: 20,
|
||||
height: 25 / 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.2,
|
||||
fontFamilyFallback: _sansFallback,
|
||||
);
|
||||
|
||||
/// 22 / 28 / w700 — 「我的」页用户名
|
||||
static const titleLg = TextStyle(
|
||||
fontSize: 22,
|
||||
height: 28 / 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamilyFallback: _sansFallback,
|
||||
);
|
||||
|
||||
/// 28 / 34 / w700 / ls -0.02em — `.font-nav-large-title-mobile`
|
||||
static const navLargeTitle = TextStyle(
|
||||
fontSize: 28,
|
||||
height: 34 / 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.56,
|
||||
fontFamilyFallback: _sansFallback,
|
||||
);
|
||||
|
||||
/// 32 / 40 / w700 — 登录页品牌字
|
||||
static const displayMd = TextStyle(
|
||||
fontSize: 32,
|
||||
height: 40 / 32,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamilyFallback: _sansFallback,
|
||||
);
|
||||
|
||||
/// 40 / 48 / w700 — 收益页总余额
|
||||
static const displayLg = TextStyle(
|
||||
fontSize: 40,
|
||||
height: 48 / 40,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamilyFallback: _sansFallback,
|
||||
);
|
||||
|
||||
/// 金额 / 编号等需要等宽对齐的场景
|
||||
static const dataMono = TextStyle(
|
||||
fontSize: 14,
|
||||
height: 18 / 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamilyFallback: _monoFallback,
|
||||
);
|
||||
}
|
||||
|
||||
/// 全局默认文字样式,对应 `global.scss` 里 `page` 上的字体设置。
|
||||
TextStyle get appDefaultTextStyle => const TextStyle(
|
||||
fontSize: 15,
|
||||
height: 1.4,
|
||||
color: AppColors.onSurface,
|
||||
fontFamilyFallback: AppTypography._sansFallback,
|
||||
);
|
||||
@@ -0,0 +1,45 @@
|
||||
/// 日期格式化工具。
|
||||
String _pad(int n) => n.toString().padLeft(2, '0');
|
||||
|
||||
/// 统一转 DateTime:兼容 `YYYY-MM-DD`、ISO 字符串与时间戳。
|
||||
DateTime? parseDate(Object? v) {
|
||||
if (v == null) return null;
|
||||
if (v is DateTime) return v;
|
||||
if (v is num) return DateTime.fromMillisecondsSinceEpoch(v.toInt());
|
||||
final s = v.toString();
|
||||
if (s.isEmpty) return null;
|
||||
return DateTime.tryParse(s) ?? DateTime.tryParse(s.replaceAll('-', '/'));
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD`
|
||||
String formatDate(Object? v) {
|
||||
final d = parseDate(v);
|
||||
if (d == null) return '-';
|
||||
return '${d.year}-${_pad(d.month)}-${_pad(d.day)}';
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD HH:mm`
|
||||
String formatDateTime(Object? v) {
|
||||
final d = parseDate(v);
|
||||
if (d == null) return '-';
|
||||
return '${formatDate(d)} ${_pad(d.hour)}:${_pad(d.minute)}';
|
||||
}
|
||||
|
||||
/// 今天的 `YYYY-MM-DD`
|
||||
String todayStr() => formatDate(DateTime.now());
|
||||
|
||||
/// 金额格式化:千分位 + 两位小数,对应源页面 `formatNumber`。
|
||||
String formatMoney(num? value) {
|
||||
final v = (value ?? 0).toDouble();
|
||||
final fixed = v.toStringAsFixed(2);
|
||||
final parts = fixed.split('.');
|
||||
final intPart = parts[0];
|
||||
final negative = intPart.startsWith('-');
|
||||
final digits = negative ? intPart.substring(1) : intPart;
|
||||
final buf = StringBuffer();
|
||||
for (var i = 0; i < digits.length; i++) {
|
||||
if (i > 0 && (digits.length - i) % 3 == 0) buf.write(',');
|
||||
buf.write(digits[i]);
|
||||
}
|
||||
return '${negative ? '-' : ''}$buf.${parts[1]}';
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/// 后端主键为 BIGINT,经 JSON 序列化后是字符串(如 `"1"`)。
|
||||
/// 统一归一化为 int,避免与表单/路由里的 int 型 id 做比较时失效。
|
||||
Object? toNumId(Object? v) {
|
||||
if (v == null || v == '') return v;
|
||||
if (v is int) return v;
|
||||
if (v is num) return v.toInt();
|
||||
final n = int.tryParse(v.toString());
|
||||
return n ?? v;
|
||||
}
|
||||
|
||||
int? toIntOrNull(Object? v) {
|
||||
final n = toNumId(v);
|
||||
return n is int ? n : null;
|
||||
}
|
||||
|
||||
int toIntOr(Object? v, int fallback) => toIntOrNull(v) ?? fallback;
|
||||
|
||||
double toDoubleOr(Object? v, double fallback) {
|
||||
if (v == null) return fallback;
|
||||
if (v is num) return v.toDouble();
|
||||
return double.tryParse(v.toString()) ?? fallback;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/providers.dart';
|
||||
import '../../../core/storage/token_storage.dart';
|
||||
import '../data/auth_api.dart';
|
||||
import '../data/auth_models.dart';
|
||||
|
||||
final authApiProvider = Provider<AuthApi>(
|
||||
(ref) => AuthApi(ref.watch(apiClientProvider)),
|
||||
);
|
||||
|
||||
class SessionState {
|
||||
const SessionState({this.worker, required this.isLoggedIn});
|
||||
|
||||
final WorkerInfo? worker;
|
||||
final bool isLoggedIn;
|
||||
|
||||
SessionState copyWith({WorkerInfo? worker, bool? isLoggedIn}) =>
|
||||
SessionState(
|
||||
worker: worker ?? this.worker,
|
||||
isLoggedIn: isLoggedIn ?? this.isLoggedIn,
|
||||
);
|
||||
}
|
||||
|
||||
/// 登录态。对应源 `store/user.ts` 的登录/登出与本地缓存恢复。
|
||||
class SessionController extends Notifier<SessionState> {
|
||||
TokenStorage get _storage => ref.read(tokenStorageProvider);
|
||||
|
||||
@override
|
||||
SessionState build() {
|
||||
// 拦截器判定会话失效(401)时,清空本地会话并回到登录页。
|
||||
final sub = ref
|
||||
.watch(apiClientProvider)
|
||||
.onUnauthorized
|
||||
.listen((_) => clearLocal());
|
||||
ref.onDispose(sub.cancel);
|
||||
|
||||
final cached = _storage.userInfo;
|
||||
return SessionState(
|
||||
worker: cached == null ? null : WorkerInfo.fromJson(cached),
|
||||
isLoggedIn: _storage.hasSession,
|
||||
);
|
||||
}
|
||||
|
||||
/// 登录成功后写入 token 与电工信息。
|
||||
Future<void> login({
|
||||
required String phone,
|
||||
required String password,
|
||||
}) async {
|
||||
final res = await ref
|
||||
.read(authApiProvider)
|
||||
.login(phone: phone, password: password);
|
||||
if (!res.isOk || res.data == null) {
|
||||
throw AuthException(res.message.isEmpty ? '登录失败' : res.message);
|
||||
}
|
||||
|
||||
final data = res.data!;
|
||||
await _storage.setAccessToken(data.token);
|
||||
await _storage.setUserInfo(data.workerInfo.toJson());
|
||||
state = SessionState(worker: data.workerInfo, isLoggedIn: true);
|
||||
}
|
||||
|
||||
/// 从后端刷新电工信息(「我的」页进入时调用)。
|
||||
Future<WorkerInfo?> refreshProfile() async {
|
||||
try {
|
||||
final res = await ref.read(authApiProvider).profile();
|
||||
if (res.isOk && res.data != null) {
|
||||
final info = res.data!.basic;
|
||||
await _storage.setUserInfo(info.toJson());
|
||||
state = SessionState(worker: info, isLoggedIn: true);
|
||||
return info;
|
||||
}
|
||||
} catch (_) {
|
||||
// 拉取失败保持原状态
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await _storage.clearSession();
|
||||
state = const SessionState(isLoggedIn: false, worker: null);
|
||||
}
|
||||
|
||||
/// 清空本地会话但不跳转(被拦截器判定会话失效时调用)。
|
||||
Future<void> clearLocal() => logout();
|
||||
}
|
||||
|
||||
final sessionProvider = NotifierProvider<SessionController, SessionState>(
|
||||
SessionController.new,
|
||||
);
|
||||
|
||||
/// 会话层业务异常,携带后端返回的可读 message。
|
||||
class AuthException implements Exception {
|
||||
const AuthException(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/network/api_response.dart';
|
||||
import 'auth_models.dart';
|
||||
|
||||
/// 认证与个人资料接口,对应后端 `/api/worker-app/auth/*` 与 `/profile`。
|
||||
class AuthApi {
|
||||
AuthApi(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
Future<ApiResponse<LoginResult>> login({
|
||||
required String phone,
|
||||
required String password,
|
||||
}) => _client.post<LoginResult>(
|
||||
'/worker-app/auth/login',
|
||||
body: {'phone': phone, 'password': password},
|
||||
parse: (d) => LoginResult.fromJson((d as Map).cast<String, dynamic>()),
|
||||
needAuth: false,
|
||||
);
|
||||
|
||||
Future<ApiResponse<WorkerInfo>> register({
|
||||
required String workerName,
|
||||
required String phone,
|
||||
required String password,
|
||||
String? idCard,
|
||||
String? workerTitle,
|
||||
}) => _client.post<WorkerInfo>(
|
||||
'/worker-app/auth/register',
|
||||
body: {
|
||||
'workerName': workerName,
|
||||
'phone': phone,
|
||||
'password': password,
|
||||
if (idCard != null && idCard.isNotEmpty) 'idCard': idCard,
|
||||
if (workerTitle != null && workerTitle.isNotEmpty) 'workerTitle': workerTitle,
|
||||
},
|
||||
parse: (d) => WorkerInfo.fromJson((d as Map).cast<String, dynamic>()),
|
||||
needAuth: false,
|
||||
);
|
||||
|
||||
Future<ApiResponse<WorkerProfile>> profile() => _client.get<WorkerProfile>(
|
||||
'/worker-app/profile',
|
||||
parse: (d) => WorkerProfile.fromJson((d as Map).cast<String, dynamic>()),
|
||||
);
|
||||
|
||||
Future<ApiResponse<WorkerInfo>> updateProfile({
|
||||
String? workerName,
|
||||
String? idCard,
|
||||
String? workerTitle,
|
||||
}) => _client.put<WorkerInfo>(
|
||||
'/worker-app/profile',
|
||||
body: {
|
||||
'workerName': ?workerName,
|
||||
'idCard': ?idCard,
|
||||
'workerTitle': ?workerTitle,
|
||||
},
|
||||
parse: (d) => WorkerInfo.fromJson((d as Map).cast<String, dynamic>()),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import '../../../core/utils/id_util.dart';
|
||||
|
||||
/// 电工基础信息,对应后端 `WorkerAppService.basicInfo()`。
|
||||
class WorkerInfo {
|
||||
const WorkerInfo({
|
||||
required this.id,
|
||||
required this.workerName,
|
||||
required this.phone,
|
||||
required this.workerTitle,
|
||||
required this.auditStatus,
|
||||
required this.status,
|
||||
required this.rating,
|
||||
required this.completedCount,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String workerName;
|
||||
final String phone;
|
||||
final String workerTitle;
|
||||
|
||||
/// 审核状态:0 待审核 / 1 已通过 / 2 已驳回
|
||||
final int auditStatus;
|
||||
|
||||
/// 在职状态:0 离职 / 1 在职
|
||||
final int status;
|
||||
|
||||
final double rating;
|
||||
final int completedCount;
|
||||
|
||||
String get displayName => workerName.isNotEmpty ? workerName : phone;
|
||||
|
||||
/// 头像占位文字:取显示名首个字符(按 Unicode 码点截取,兼容中文)。
|
||||
String get avatarText =>
|
||||
displayName.isEmpty ? '电' : String.fromCharCode(displayName.runes.first);
|
||||
|
||||
bool get isApproved => auditStatus == 1;
|
||||
|
||||
String get auditStatusText => switch (auditStatus) {
|
||||
0 => '待审核',
|
||||
1 => '已通过',
|
||||
2 => '已驳回',
|
||||
_ => '未知',
|
||||
};
|
||||
|
||||
factory WorkerInfo.fromJson(Map<String, dynamic> json) => WorkerInfo(
|
||||
id: toIntOr(json['id'], 0),
|
||||
workerName: json['workerName'] as String? ?? '',
|
||||
phone: json['phone'] as String? ?? '',
|
||||
workerTitle: json['workerTitle'] as String? ?? '',
|
||||
auditStatus: toIntOr(json['auditStatus'], 0),
|
||||
status: toIntOr(json['status'], 0),
|
||||
rating: toDoubleOr(json['rating'], 5),
|
||||
completedCount: toIntOr(json['completedCount'], 0),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'workerName': workerName,
|
||||
'phone': phone,
|
||||
'workerTitle': workerTitle,
|
||||
'auditStatus': auditStatus,
|
||||
'status': status,
|
||||
'rating': rating,
|
||||
'completedCount': completedCount,
|
||||
};
|
||||
}
|
||||
|
||||
/// 登录返回体。
|
||||
class LoginResult {
|
||||
const LoginResult({required this.token, required this.workerInfo});
|
||||
|
||||
final String token;
|
||||
final WorkerInfo workerInfo;
|
||||
|
||||
factory LoginResult.fromJson(Map<String, dynamic> json) => LoginResult(
|
||||
token: json['token'] as String? ?? '',
|
||||
workerInfo: WorkerInfo.fromJson(
|
||||
(json['workerInfo'] as Map?)?.cast<String, dynamic>() ?? const {},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 电工资质证照,对应 `worker_cert` 表。
|
||||
class CertInfo {
|
||||
const CertInfo({
|
||||
required this.id,
|
||||
required this.certType,
|
||||
required this.certNo,
|
||||
required this.issueAuthority,
|
||||
required this.issueDate,
|
||||
required this.expireDate,
|
||||
required this.certImageUrl,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String certType;
|
||||
final String certNo;
|
||||
final String issueAuthority;
|
||||
final String issueDate;
|
||||
final String expireDate;
|
||||
final String certImageUrl;
|
||||
|
||||
/// 有效期状态:0 正常 / 1 即将到期 / 2 已过期
|
||||
final int status;
|
||||
|
||||
factory CertInfo.fromJson(Map<String, dynamic> json) => CertInfo(
|
||||
id: toIntOr(json['id'], 0),
|
||||
certType: json['certType'] as String? ?? '',
|
||||
certNo: json['certNo'] as String? ?? '',
|
||||
issueAuthority: json['issueAuthority'] as String? ?? '',
|
||||
issueDate: json['issueDate'] as String? ?? '',
|
||||
expireDate: json['expireDate'] as String? ?? '',
|
||||
certImageUrl: json['certImageUrl'] as String? ?? '',
|
||||
status: toIntOr(json['status'], 0),
|
||||
);
|
||||
}
|
||||
|
||||
/// 个人资料,对应 `GET /api/worker-app/profile`。
|
||||
class WorkerProfile {
|
||||
const WorkerProfile({
|
||||
required this.basic,
|
||||
required this.idCard,
|
||||
required this.auditRemark,
|
||||
required this.certStatus,
|
||||
required this.certs,
|
||||
});
|
||||
|
||||
final WorkerInfo basic;
|
||||
final String idCard;
|
||||
final String auditRemark;
|
||||
|
||||
/// 资质汇总状态:none / valid / expiring / expired
|
||||
final String certStatus;
|
||||
final List<CertInfo> certs;
|
||||
|
||||
/// 「我的」页证书区取首张证书展示(源页面只展示一张「电工职业资格证」)。
|
||||
CertInfo? get primaryCert => certs.isEmpty ? null : certs.first;
|
||||
|
||||
factory WorkerProfile.fromJson(Map<String, dynamic> json) => WorkerProfile(
|
||||
basic: WorkerInfo.fromJson(json),
|
||||
idCard: json['idCard'] as String? ?? '',
|
||||
auditRemark: json['auditRemark'] as String? ?? '',
|
||||
certStatus: json['certStatus'] as String? ?? 'none',
|
||||
certs:
|
||||
(json['certs'] as List?)
|
||||
?.map((e) => CertInfo.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList() ??
|
||||
const [],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,717 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/env.dart';
|
||||
import '../../../core/providers.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../shared/widgets/app_feedback.dart';
|
||||
import '../../../shared/widgets/x_common.dart';
|
||||
import '../../../shared/widgets/x_icon.dart';
|
||||
import '../application/session_controller.dart';
|
||||
|
||||
/// 是否在 release 包里也显示「服务端设置」入口。
|
||||
///
|
||||
/// 默认关闭,正式包不暴露改地址的口子;打内部测试包时用
|
||||
/// `--dart-define=ENABLE_SERVER_SETTINGS=true` 打开。
|
||||
const _serverSettingsEnabled = bool.fromEnvironment('ENABLE_SERVER_SETTINGS');
|
||||
|
||||
/// 登录页,对应源 `pages/login/index.vue`。
|
||||
///
|
||||
/// **与源的差异(已与用户确认)**:源为「手机号 + 验证码」,后端只支持
|
||||
/// 「手机号 + 密码」,故此处改为手机号 + 密码,并新增注册入口。
|
||||
class LoginPage extends ConsumerStatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
final _phone = TextEditingController();
|
||||
final _password = TextEditingController();
|
||||
|
||||
bool _agree = false;
|
||||
bool _obscure = true;
|
||||
bool _loading = false;
|
||||
String _error = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_phone.addListener(_onChanged);
|
||||
_password.addListener(_onChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phone.dispose();
|
||||
_password.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onChanged() => setState(() {});
|
||||
|
||||
bool get _canLogin =>
|
||||
_phone.text.length == 11 && _password.text.length >= 6 && _agree;
|
||||
|
||||
Future<void> _handleLogin() async {
|
||||
if (!_canLogin) {
|
||||
if (!_agree) _error = '请先阅读并同意服务协议与隐私政策';
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = '';
|
||||
});
|
||||
try {
|
||||
await ref
|
||||
.read(sessionProvider.notifier)
|
||||
.login(phone: _phone.text.trim(), password: _password.text);
|
||||
if (!mounted) return;
|
||||
context.go('/tabs');
|
||||
} on AuthException catch (e) {
|
||||
if (mounted) setState(() => _error = e.message);
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _error = '网络异常,请检查服务端地址');
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFFF0F4FF), Color(0xFFFAF9FE)],
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 80, 24, 60),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildLogoSection(),
|
||||
_buildFormSection(),
|
||||
_buildOtherLogin(),
|
||||
_buildAgreement(),
|
||||
const SizedBox(height: 24),
|
||||
_buildRegisterEntry(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 测试用「服务端设置」入口:调试版常显,release 需显式开关
|
||||
if (kDebugMode || _serverSettingsEnabled)
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: SafeArea(
|
||||
child: GestureDetector(
|
||||
onTap: _openServerSettings,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: XIcon(
|
||||
'settings',
|
||||
size: 22,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openServerSettings() => showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: AppColors.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)),
|
||||
),
|
||||
builder: (_) => const _ServerSettingsSheet(),
|
||||
);
|
||||
|
||||
// ---- 各区块 ----
|
||||
|
||||
Widget _buildLogoSection() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 48),
|
||||
child: Column(
|
||||
children: [
|
||||
// .logo 96×96 / #0052ff / radius 20 / 0 8px 24px rgba(0,82,255,.3)
|
||||
Container(
|
||||
width: 96,
|
||||
height: 96,
|
||||
margin: const EdgeInsets.only(bottom: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x4D0052FF),
|
||||
blurRadius: 24,
|
||||
offset: Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const XIcon('shandian', size: 48, color: Colors.white),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
'VoltLink',
|
||||
style: AppTypography.displayMd.copyWith(
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'欢迎回到 VoltLink',
|
||||
style: TextStyle(fontSize: 16, color: AppColors.textMuted),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFormSection() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 48),
|
||||
child: Column(
|
||||
children: [
|
||||
// .input-group:白底 / radius 16 / padding 1px
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerLowest,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildPhoneRow(),
|
||||
_buildPasswordRow(),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildLoginButton(),
|
||||
if (_error.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 14),
|
||||
child: Text(
|
||||
_error,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 13, color: AppColors.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 手机号行:+86 国家码 + 分隔线 + 输入框。
|
||||
Widget _buildPhoneRow() {
|
||||
return Container(
|
||||
height: 56,
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: AppColors.surfaceContainerHighest)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'+86',
|
||||
style: AppTypography.bodyLg.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Text(
|
||||
'▼',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.textMuted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(width: 1, height: 32, color: AppColors.surfaceContainerHighest),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _phone,
|
||||
keyboardType: TextInputType.phone,
|
||||
maxLength: 11,
|
||||
style: const TextStyle(fontSize: 17, color: AppColors.onSurface),
|
||||
cursorColor: AppColors.primary,
|
||||
decoration: _inputDecoration('请输入手机号码'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 密码行:源为「验证码 + 获取验证码」,后端改为密码,右侧按钮换成明文切换。
|
||||
Widget _buildPasswordRow() {
|
||||
return SizedBox(
|
||||
height: 56,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _password,
|
||||
obscureText: _obscure,
|
||||
style: const TextStyle(fontSize: 17, color: AppColors.onSurface),
|
||||
cursorColor: AppColors.primary,
|
||||
decoration: _inputDecoration('请输入密码(至少 6 位)'),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _obscure = !_obscure),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Text(
|
||||
_obscure ? '显示' : '隐藏',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
InputDecoration _inputDecoration(String hint) => InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
counterText: '',
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
hintText: hint,
|
||||
hintStyle: const TextStyle(fontSize: 17, color: AppColors.textPlaceholder),
|
||||
);
|
||||
|
||||
Widget _buildLoginButton() {
|
||||
final enabled = _canLogin && !_loading;
|
||||
return GestureDetector(
|
||||
onTap: enabled ? _handleLogin : null,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: enabled ? AppColors.primary : AppColors.textPlaceholder,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
boxShadow: enabled
|
||||
? const [
|
||||
BoxShadow(
|
||||
color: Color(0x4D0052FF),
|
||||
blurRadius: 20,
|
||||
offset: Offset(0, 8),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation(Colors.white),
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
Text(
|
||||
'立即登录',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'→',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w300,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOtherLogin() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 48),
|
||||
child: Column(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 24),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 1,
|
||||
child: ColoredBox(color: AppColors.surfaceContainerHighest),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
'微信快捷登录',
|
||||
style: TextStyle(fontSize: 13, color: AppColors.textMuted),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 1,
|
||||
child: ColoredBox(color: AppColors.surfaceContainerHighest),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => showAppToast('微信登录暂未开放'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF07C160),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const XIcon('weixin', size: 32, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAgreement() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _agree = !_agree),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
margin: const EdgeInsets.only(top: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: _agree ? AppColors.primary : Colors.transparent,
|
||||
border: Border.all(
|
||||
color: _agree ? AppColors.primary : AppColors.textPlaceholder,
|
||||
width: 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: _agree
|
||||
? const Text(
|
||||
'✓',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
height: 1,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.6,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
children: [
|
||||
const TextSpan(text: '我已阅读并同意'),
|
||||
_link('《VoltLink服务协议》'),
|
||||
const TextSpan(text: '与'),
|
||||
_link('《隐私政策》'),
|
||||
const TextSpan(text: ',并授权 VoltLink 在登录时获取我的相关信息。'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TextSpan _link(String text) => TextSpan(
|
||||
text: text,
|
||||
style: const TextStyle(color: AppColors.primary),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () => showAppToast('$text 暂未开放'),
|
||||
);
|
||||
|
||||
Widget _buildRegisterEntry() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'还没有账号?',
|
||||
style: TextStyle(fontSize: 14, color: AppColors.textMuted),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => context.push('/register'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 6, horizontal: 4),
|
||||
child: Text(
|
||||
'立即注册',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 调试版「服务端设置」弹窗:只让改 IP 与端口,
|
||||
/// 协议与路径沿用 [Env.apiBaseUrl] 的编译期配置。
|
||||
class _ServerSettingsSheet extends ConsumerStatefulWidget {
|
||||
const _ServerSettingsSheet();
|
||||
|
||||
@override
|
||||
ConsumerState<_ServerSettingsSheet> createState() =>
|
||||
_ServerSettingsSheetState();
|
||||
}
|
||||
|
||||
class _ServerSettingsSheetState extends ConsumerState<_ServerSettingsSheet> {
|
||||
late final TextEditingController _host;
|
||||
late final TextEditingController _port;
|
||||
String _error = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final saved = ref.read(serverAddressProvider);
|
||||
_host = TextEditingController(
|
||||
text: saved.host.isEmpty ? Env.defaultHost : saved.host,
|
||||
);
|
||||
_port = TextEditingController(
|
||||
text: saved.port.isEmpty ? '${Env.defaultPort}' : saved.port,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_host.dispose();
|
||||
_port.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String get _preview {
|
||||
final host = _host.text.trim();
|
||||
return Env.buildBaseUrl(
|
||||
host.isEmpty ? Env.defaultHost : host,
|
||||
_port.text.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final host = _host.text.trim();
|
||||
final port = _port.text.trim();
|
||||
|
||||
if (host.isEmpty) {
|
||||
setState(() => _error = '请输入服务端 IP');
|
||||
return;
|
||||
}
|
||||
if (host.contains('://') || host.contains('/') || host.contains(':')) {
|
||||
setState(() => _error = '只填 IP 或域名,不要带 http:// 和端口');
|
||||
return;
|
||||
}
|
||||
final parsedPort = int.tryParse(port);
|
||||
if (parsedPort == null || parsedPort < 1 || parsedPort > 65535) {
|
||||
setState(() => _error = '端口需为 1 - 65535 的整数');
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存后 serverAddressProvider 变化会重建 ApiClient,立即生效
|
||||
await ref.read(serverAddressProvider.notifier).save(host, port);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
showAppToast('已切换到 ${Env.buildBaseUrl(host, port)}', success: true);
|
||||
}
|
||||
|
||||
Future<void> _reset() async {
|
||||
await ref.read(serverAddressProvider.notifier).reset();
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
showAppToast('已恢复默认地址', success: true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 20, 24, 28),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'服务端设置',
|
||||
style: AppTypography.headlineMd.copyWith(
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'保存后立即生效,无需重启 App。',
|
||||
style: TextStyle(fontSize: 13, color: AppColors.textMuted),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildField(
|
||||
label: '服务端 IP',
|
||||
controller: _host,
|
||||
hintText: '如 192.168.3.29',
|
||||
keyboardType: TextInputType.url,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildField(
|
||||
label: '端口',
|
||||
controller: _port,
|
||||
hintText: '如 7001',
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
// 保存前实时预览拼接结果,避免地址拼错
|
||||
ListenableBuilder(
|
||||
listenable: Listenable.merge([_host, _port]),
|
||||
builder: (context, _) => Text(
|
||||
'生效地址:$_preview',
|
||||
style: AppTypography.dataMono.copyWith(
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_error.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(
|
||||
_error,
|
||||
style: const TextStyle(fontSize: 13, color: AppColors.error),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: XPrimaryButton(
|
||||
label: '恢复默认',
|
||||
onPressed: _reset,
|
||||
background: AppColors.surfaceContainerHigh,
|
||||
foreground: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: XPrimaryButton(label: '保存', onPressed: _save),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildField({
|
||||
required String label,
|
||||
required TextEditingController controller,
|
||||
required String hintText,
|
||||
required TextInputType keyboardType,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: AppTypography.bodySm.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 48,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceBright,
|
||||
border: Border.all(color: AppColors.borderSoft),
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
keyboardType: keyboardType,
|
||||
autocorrect: false,
|
||||
style: const TextStyle(fontSize: 15, color: AppColors.onSurface),
|
||||
cursorColor: AppColors.primary,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
hintText: hintText,
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppColors.textPlaceholder,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/network/api_response.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../shared/widgets/app_feedback.dart';
|
||||
import '../../../shared/widgets/x_card.dart';
|
||||
import '../../../shared/widgets/x_common.dart';
|
||||
import '../../../shared/widgets/x_top_bar.dart';
|
||||
import '../application/session_controller.dart';
|
||||
|
||||
/// 注册页。
|
||||
///
|
||||
/// 源工程登录页只有「手机号 + 验证码」,没有注册页;后端 `/auth/register`
|
||||
/// 支持手机号 + 密码开户,因此这里补一个与登录页同一套视觉语言的注册表单。
|
||||
class RegisterPage extends ConsumerStatefulWidget {
|
||||
const RegisterPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<RegisterPage> createState() => _RegisterPageState();
|
||||
}
|
||||
|
||||
class _RegisterPageState extends ConsumerState<RegisterPage> {
|
||||
final _name = TextEditingController();
|
||||
final _phone = TextEditingController();
|
||||
final _password = TextEditingController();
|
||||
final _idCard = TextEditingController();
|
||||
final _title = TextEditingController();
|
||||
|
||||
bool _loading = false;
|
||||
String _error = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
for (final c in [_name, _phone, _password]) {
|
||||
c.addListener(_onChanged);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_name.dispose();
|
||||
_phone.dispose();
|
||||
_password.dispose();
|
||||
_idCard.dispose();
|
||||
_title.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onChanged() => setState(() {});
|
||||
|
||||
bool get _canSubmit =>
|
||||
_name.text.trim().isNotEmpty &&
|
||||
_phone.text.length == 11 &&
|
||||
_password.text.length >= 6;
|
||||
|
||||
Future<void> _handleRegister() async {
|
||||
if (!_canSubmit) {
|
||||
setState(() => _error = '请填写姓名、11 位手机号与至少 6 位密码');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = '';
|
||||
});
|
||||
try {
|
||||
final res = await ref
|
||||
.read(authApiProvider)
|
||||
.register(
|
||||
workerName: _name.text.trim(),
|
||||
phone: _phone.text.trim(),
|
||||
password: _password.text,
|
||||
idCard: _idCard.text.trim(),
|
||||
workerTitle: _title.text.trim(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (!res.isOk) {
|
||||
setState(() => _error = res.message.isEmpty ? '注册失败' : res.message);
|
||||
return;
|
||||
}
|
||||
showAppToast('注册成功,请登录', success: true);
|
||||
context.pop();
|
||||
} on ApiException catch (e) {
|
||||
if (mounted) setState(() => _error = e.message);
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _error = '网络异常,请稍后重试');
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: XPageShell(
|
||||
topBar: XTopBar.nav(title: '注册账号', onBack: () => context.pop()),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 40),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
XCard(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_Field(
|
||||
label: '姓名',
|
||||
hint: '请输入真实姓名',
|
||||
controller: _name,
|
||||
required: true,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_Field(
|
||||
label: '手机号',
|
||||
hint: '请输入 11 位手机号',
|
||||
controller: _phone,
|
||||
required: true,
|
||||
keyboardType: TextInputType.phone,
|
||||
maxLength: 11,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_Field(
|
||||
label: '密码',
|
||||
hint: '至少 6 位',
|
||||
controller: _password,
|
||||
required: true,
|
||||
obscure: true,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_Field(
|
||||
label: '身份证号',
|
||||
hint: '选填',
|
||||
controller: _idCard,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_Field(
|
||||
label: '工种 / 职称',
|
||||
hint: '选填,如 高压电工',
|
||||
controller: _title,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
XPrimaryButton(
|
||||
label: '提交注册',
|
||||
height: 56,
|
||||
radius: AppRadius.xl,
|
||||
loading: _loading,
|
||||
onPressed: _canSubmit ? _handleRegister : null,
|
||||
shadows: AppShadows.primaryButton,
|
||||
),
|
||||
if (_error.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 14),
|
||||
child: Text(
|
||||
_error,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 13, color: AppColors.error),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'注册后需等待平台审核通过,方可接单。',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13, color: AppColors.textMuted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Field extends StatelessWidget {
|
||||
const _Field({
|
||||
required this.label,
|
||||
required this.hint,
|
||||
required this.controller,
|
||||
this.required = false,
|
||||
this.obscure = false,
|
||||
this.keyboardType,
|
||||
this.maxLength,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String hint;
|
||||
final TextEditingController controller;
|
||||
final bool required;
|
||||
final bool obscure;
|
||||
final TextInputType? keyboardType;
|
||||
final int? maxLength;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: AppTypography.bodySm.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (required)
|
||||
const Text(
|
||||
' *',
|
||||
style: TextStyle(fontSize: 13, color: AppColors.error),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 48,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceBright,
|
||||
border: Border.all(color: AppColors.borderSoft),
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
obscureText: obscure,
|
||||
keyboardType: keyboardType,
|
||||
maxLength: maxLength,
|
||||
style: const TextStyle(fontSize: 15, color: AppColors.onSurface),
|
||||
cursorColor: AppColors.primary,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
counterText: '',
|
||||
hintText: hint,
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppColors.textPlaceholder,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/network/api_response.dart';
|
||||
import '../../../core/providers.dart';
|
||||
import '../data/earnings_api.dart';
|
||||
import '../data/earnings_models.dart';
|
||||
|
||||
final earningsApiProvider = Provider<EarningsApi>(
|
||||
(ref) => EarningsApi(ref.watch(apiClientProvider)),
|
||||
);
|
||||
|
||||
final earningsSummaryProvider =
|
||||
FutureProvider.autoDispose<EarningsSummary>((ref) async {
|
||||
final res = await ref.watch(earningsApiProvider).summary();
|
||||
if (!res.isOk) throw ApiException(res.message);
|
||||
if (res.data == null) throw const ApiException('收益数据为空');
|
||||
return res.data!;
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/network/api_response.dart';
|
||||
import 'earnings_models.dart';
|
||||
|
||||
/// 收益接口,对应后端 `/api/worker-app/earnings/summary`。
|
||||
class EarningsApi {
|
||||
EarningsApi(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
Future<ApiResponse<EarningsSummary>> summary() =>
|
||||
_client.get<EarningsSummary>(
|
||||
'/worker-app/earnings/summary',
|
||||
parse: (d) => EarningsSummary.fromJson((d as Map).cast<String, dynamic>()),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import '../../../core/utils/id_util.dart';
|
||||
|
||||
/// 收益流水,对应后端 `earningsSummary().data.transactions[]`。
|
||||
///
|
||||
/// 说明:后端没有提现记录来源,因此实际只会返回 `type == 'earning'` 的入账流水。
|
||||
class EarningTransaction {
|
||||
const EarningTransaction({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.amount,
|
||||
required this.status,
|
||||
required this.icon,
|
||||
});
|
||||
|
||||
final String id;
|
||||
|
||||
/// earning | withdrawal
|
||||
final String type;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final double amount;
|
||||
|
||||
/// settled | pending | completed
|
||||
final String status;
|
||||
final String icon;
|
||||
|
||||
bool get isWithdrawal => type == 'withdrawal';
|
||||
|
||||
String get statusText => switch (status) {
|
||||
'settled' => '已结算',
|
||||
'pending' => '处理中',
|
||||
'completed' => '已完成',
|
||||
_ => status,
|
||||
};
|
||||
|
||||
factory EarningTransaction.fromJson(Map<String, dynamic> json) =>
|
||||
EarningTransaction(
|
||||
id: json['id'] as String? ?? '',
|
||||
type: json['type'] as String? ?? 'earning',
|
||||
title: json['title'] as String? ?? '',
|
||||
subtitle: json['subtitle'] as String? ?? '',
|
||||
amount: toDoubleOr(json['amount'], 0),
|
||||
status: json['status'] as String? ?? 'settled',
|
||||
icon: json['icon'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// 收益汇总,对应 `GET /api/worker-app/earnings/summary`。
|
||||
class EarningsSummary {
|
||||
const EarningsSummary({
|
||||
required this.totalBalance,
|
||||
required this.withdrawableAmount,
|
||||
required this.monthlyEarnings,
|
||||
required this.weeklyTrend,
|
||||
required this.transactions,
|
||||
});
|
||||
|
||||
final double totalBalance;
|
||||
final double withdrawableAmount;
|
||||
final double monthlyEarnings;
|
||||
|
||||
/// 近 7 天每日收益(index 6 = 今天)。
|
||||
final List<double> weeklyTrend;
|
||||
final List<EarningTransaction> transactions;
|
||||
|
||||
/// 柱状图高度百分比:以 7 日峰值为 100%,全 0 时给一个最小可见高度。
|
||||
List<double> get trendPercents {
|
||||
final max = weeklyTrend.isEmpty
|
||||
? 0.0
|
||||
: weeklyTrend.reduce((a, b) => a > b ? a : b);
|
||||
if (max <= 0) return List.filled(weeklyTrend.length, 8);
|
||||
return weeklyTrend.map((v) => (v / max * 100).clamp(8, 100).toDouble()).toList();
|
||||
}
|
||||
|
||||
factory EarningsSummary.fromJson(Map<String, dynamic> json) => EarningsSummary(
|
||||
totalBalance: toDoubleOr(json['totalBalance'], 0),
|
||||
withdrawableAmount: toDoubleOr(json['withdrawableAmount'], 0),
|
||||
monthlyEarnings: toDoubleOr(json['monthlyEarnings'], 0),
|
||||
weeklyTrend:
|
||||
(json['weeklyTrend'] as List?)?.map((e) => toDoubleOr(e, 0)).toList() ??
|
||||
const [0, 0, 0, 0, 0, 0, 0],
|
||||
transactions:
|
||||
(json['transactions'] as List?)
|
||||
?.map(
|
||||
(e) => EarningTransaction.fromJson(
|
||||
(e as Map).cast<String, dynamic>(),
|
||||
),
|
||||
)
|
||||
.toList() ??
|
||||
const [],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/providers.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../shared/widgets/app_feedback.dart';
|
||||
import '../../../shared/widgets/x_card.dart';
|
||||
import '../../../shared/widgets/x_common.dart';
|
||||
import '../../../shared/widgets/x_icon.dart';
|
||||
import '../../../shared/widgets/x_top_bar.dart';
|
||||
import '../application/earnings_providers.dart';
|
||||
import '../data/earnings_models.dart';
|
||||
|
||||
/// rgba(255,255,255,0.8) —— 余额卡内的次级文字。
|
||||
const _white80 = Color(0xCCFFFFFF);
|
||||
|
||||
/// 柱状图绘图区高度(趋势卡总高 160,余下留给 8px 间距与周标签)。
|
||||
const _barPlotHeight = 130.0;
|
||||
|
||||
/// 「收益」Tab,逐像素复刻源 `src/pages/earnings/index.vue`。
|
||||
///
|
||||
/// 顶栏由本页渲染;底部 Tab 栏由 `TabsPage` 统一提供,故此处不渲染导航。
|
||||
class EarningsTab extends ConsumerWidget {
|
||||
const EarningsTab({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final summary = ref.watch(earningsSummaryProvider);
|
||||
final unread = ref.watch(noticeUnreadCountProvider);
|
||||
|
||||
return XPageShell(
|
||||
topBar: XTopBar.brand(
|
||||
height: 56,
|
||||
logoSize: 28,
|
||||
titleSize: 18,
|
||||
titleWeight: FontWeight.w600,
|
||||
trailingBadge: unread,
|
||||
onTrailingTap: () => context.push('/notice'),
|
||||
),
|
||||
child: RefreshIndicator(
|
||||
color: AppColors.primary,
|
||||
onRefresh: () async {
|
||||
ref.invalidate(earningsSummaryProvider);
|
||||
await ref.read(earningsSummaryProvider.future);
|
||||
},
|
||||
child: summary.when(
|
||||
loading: () => _placeholder('加载中...'),
|
||||
error: (error, _) => _placeholder(error.toString()),
|
||||
data: (data) => ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.only(bottom: 96),
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 24, 16, 24),
|
||||
child: XPageTitle(
|
||||
title: '收益中心',
|
||||
subtitle: '管理您的职业收入与提现详情',
|
||||
),
|
||||
),
|
||||
_balanceCard(data),
|
||||
_trendSection(data),
|
||||
_transactionsSection(data),
|
||||
_referralCard(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 加载 / 错误占位,保持可下拉(`AlwaysScrollableScrollPhysics`)。
|
||||
Widget _placeholder(String text) => ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [XPlaceholder(text: text)],
|
||||
);
|
||||
|
||||
/// 余额卡:渐变底 + 总余额 + 两列指标 + 白色提现按钮。
|
||||
Widget _balanceCard(EarningsSummary data) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [AppColors.primaryDeep, AppColors.primary],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'总余额',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _white80,
|
||||
letterSpacing: 0.24,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'¥ ${_formatAmount(data.totalBalance)}',
|
||||
style: AppTypography.displayLg.copyWith(
|
||||
color: AppColors.onPrimary,
|
||||
letterSpacing: -0.8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _balanceItem(
|
||||
'可提现金额',
|
||||
_formatAmount(data.withdrawableAmount),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _balanceItem(
|
||||
'本月累计收益',
|
||||
_formatAmount(data.monthlyEarnings),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
height: 44,
|
||||
width: double.infinity,
|
||||
child: Material(
|
||||
color: AppColors.surfaceContainerLowest,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: () => showAppToast('提现功能'),
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
XIcon('earnings', size: 20, color: AppColors.primary),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'立即提现',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _balanceItem(String label, String value) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontSize: 12, color: _white80)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'¥ $value',
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
/// 最近 7 天趋势:柱高取 `trendPercents`,高亮今天(末位)。
|
||||
Widget _trendSection(EarningsSummary data) {
|
||||
final percents = data.trendPercents;
|
||||
final labels = _weekdayLabels();
|
||||
final activeIndex = percents.isEmpty ? -1 : percents.length - 1;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'最近7天趋势',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => showAppToast('近一周'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const Row(
|
||||
children: [
|
||||
Text(
|
||||
'近一周',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 4),
|
||||
XIcon('arrow-down', size: 14, color: AppColors.primary),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
XCard(
|
||||
radius: AppRadius.lg,
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: SizedBox(
|
||||
height: 160,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
for (var i = 0; i < percents.length; i++)
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
FractionallySizedBox(
|
||||
widthFactor: 0.8,
|
||||
child: Container(
|
||||
height: percents[i] / 100 * _barPlotHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: i == activeIndex
|
||||
? AppColors.primary
|
||||
: AppColors.primaryFixedDim,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
labels[i % labels.length],
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: i == activeIndex
|
||||
? FontWeight.w600
|
||||
: FontWeight.w400,
|
||||
color: i == activeIndex
|
||||
? AppColors.primary
|
||||
: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 收益明细:入账 / 提现两种配色 + 金额与状态。
|
||||
Widget _transactionsSection(EarningsSummary data) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'收益明细',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => showAppToast('查看全部'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const Text(
|
||||
'查看全部',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
for (var i = 0; i < data.transactions.length; i++) ...[
|
||||
if (i > 0) const SizedBox(height: 8),
|
||||
_transactionItem(data.transactions[i]),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _transactionItem(EarningTransaction tx) {
|
||||
final withdrawal = tx.isWithdrawal;
|
||||
final settled = tx.status == 'settled' || tx.status == 'completed';
|
||||
final statusColor = settled ? AppColors.primary : AppColors.textMuted;
|
||||
|
||||
return XCard(
|
||||
radius: AppRadius.lg,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: withdrawal
|
||||
? AppColors.surfaceVariant
|
||||
: AppColors.secondaryFixed,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: XIcon(
|
||||
'lightning',
|
||||
size: 20,
|
||||
color: withdrawal
|
||||
? AppColors.onSurfaceVariant
|
||||
: AppColors.onSecondaryFixed,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
tx.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
tx.subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'${withdrawal ? '-' : '+'}¥${_formatAmount(tx.amount.abs())}',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: withdrawal
|
||||
? AppColors.onSurface
|
||||
: AppColors.tertiaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
tx.statusText,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: statusColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 邀请好友卡(源里无实际跳转,点击仅提示)。
|
||||
Widget _referralCard() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: GestureDetector(
|
||||
onTap: () => showAppToast('邀请好友加入'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.secondaryFixed,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
border: Border.all(color: AppColors.borderSoft),
|
||||
boxShadow: AppShadows.card,
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'邀请好友加入',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSecondaryFixed,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'每成功邀请一位专业电工,您将获得 ¥100 额外奖励。',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
color: Color(0xB30C006A),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 16),
|
||||
XIcon('star', size: 48, color: AppColors.primary),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `formatNumber`:千分位 + 两位小数,空值 / 0 回退 `0.00`(与源实现一致)。
|
||||
String _formatAmount(double? value) {
|
||||
if (value == null || value == 0) return '0.00';
|
||||
final parts = value.toStringAsFixed(2).split('.');
|
||||
final grouped = parts[0].replaceAllMapped(
|
||||
RegExp(r'\B(?=(\d{3})+(?!\d))'),
|
||||
(_) => ',',
|
||||
);
|
||||
return '$grouped.${parts[1]}';
|
||||
}
|
||||
|
||||
/// 周标签:index 6 为今天,向前回溯 6 天,保证标签与真实星期一致。
|
||||
List<String> _weekdayLabels() {
|
||||
const names = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||
final today = DateTime.now();
|
||||
return [
|
||||
for (var i = 6; i >= 0; i--)
|
||||
names[today.subtract(Duration(days: i)).weekday - 1],
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/network/api_response.dart';
|
||||
import '../../../core/utils/id_util.dart';
|
||||
|
||||
/// 站内消息。对应后端 `sys_notice` 的电工端视图。
|
||||
///
|
||||
/// 后端 `toJSON()` 返回原始列名,时间字段为下划线命名 `created_at`,
|
||||
/// `is_read` 为 0/1。
|
||||
class Notice {
|
||||
const Notice({
|
||||
required this.id,
|
||||
required this.noticeType,
|
||||
required this.title,
|
||||
required this.content,
|
||||
required this.bizType,
|
||||
required this.bizId,
|
||||
required this.receiverType,
|
||||
required this.ruleId,
|
||||
required this.batchId,
|
||||
required this.isRead,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
final int id;
|
||||
|
||||
/// 通知类型,如 `cert_expire`。
|
||||
final String noticeType;
|
||||
|
||||
final String title;
|
||||
final String content;
|
||||
|
||||
/// 业务类型,如 `worker_cert`。
|
||||
final String bizType;
|
||||
|
||||
/// 业务主键。
|
||||
final int? bizId;
|
||||
|
||||
/// `user` 管理端 / `worker` 电工端。
|
||||
final String receiverType;
|
||||
|
||||
/// 来源通知规则 / 群发批次,可能为空。
|
||||
final int? ruleId;
|
||||
final int? batchId;
|
||||
|
||||
final bool isRead;
|
||||
|
||||
/// 原始 `created_at`,交给 `format_util` 渲染。
|
||||
final Object? createdAt;
|
||||
|
||||
factory Notice.fromJson(Map<String, dynamic> json) => Notice(
|
||||
id: toIntOr(json['id'], 0),
|
||||
noticeType: json['noticeType'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
content: json['content'] as String? ?? '',
|
||||
bizType: json['bizType'] as String? ?? '',
|
||||
bizId: toIntOrNull(json['bizId']),
|
||||
receiverType: json['receiverType'] as String? ?? '',
|
||||
ruleId: toIntOrNull(json['ruleId']),
|
||||
batchId: toIntOrNull(json['batchId']),
|
||||
isRead: _toBool(json['isRead']),
|
||||
createdAt: json['created_at'],
|
||||
);
|
||||
|
||||
/// 复制为已读,用于标记成功后本地更新,避免整页重拉。
|
||||
Notice copiedWithRead() => Notice(
|
||||
id: id,
|
||||
noticeType: noticeType,
|
||||
title: title,
|
||||
content: content,
|
||||
bizType: bizType,
|
||||
bizId: bizId,
|
||||
receiverType: receiverType,
|
||||
ruleId: ruleId,
|
||||
batchId: batchId,
|
||||
isRead: true,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// 消息分页结果。
|
||||
class NoticePage {
|
||||
const NoticePage({
|
||||
required this.list,
|
||||
required this.total,
|
||||
required this.page,
|
||||
required this.pageSize,
|
||||
});
|
||||
|
||||
final List<Notice> list;
|
||||
final int total;
|
||||
final int page;
|
||||
final int pageSize;
|
||||
|
||||
factory NoticePage.fromJson(Map<String, dynamic> json) => NoticePage(
|
||||
list: (json['list'] as List? ?? const [])
|
||||
.map((e) => Notice.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(),
|
||||
total: toIntOr(json['total'], 0),
|
||||
page: toIntOr(json['page'], 1),
|
||||
pageSize: toIntOr(json['pageSize'], 10),
|
||||
);
|
||||
}
|
||||
|
||||
/// 站内消息中心接口,对应后端 `/api/worker-app/notices*`。
|
||||
class NoticeApi {
|
||||
NoticeApi(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
/// 消息列表。`isRead` 传 true/false 时按已读状态筛选,null 取全部。
|
||||
///
|
||||
/// 后端 `Number(query.isRead)` 解析,故这里显式传 1 / 0。
|
||||
Future<ApiResponse<NoticePage>> list({
|
||||
int page = 1,
|
||||
int pageSize = 10,
|
||||
bool? isRead,
|
||||
}) => _client.get<NoticePage>(
|
||||
'/worker-app/notices',
|
||||
query: {
|
||||
'page': page,
|
||||
'pageSize': pageSize,
|
||||
if (isRead != null) 'isRead': isRead ? 1 : 0,
|
||||
},
|
||||
parse: (d) => NoticePage.fromJson((d as Map).cast<String, dynamic>()),
|
||||
);
|
||||
|
||||
/// 未读消息数。
|
||||
Future<ApiResponse<int>> unreadCount() => _client.get<int>(
|
||||
'/worker-app/notices/unread-count',
|
||||
parse: (d) => toIntOr((d as Map)['count'], 0),
|
||||
);
|
||||
|
||||
/// 全部标记已读,返回更新条数。
|
||||
Future<ApiResponse<int>> readAll() => _client.put<int>(
|
||||
'/worker-app/notices/read-all',
|
||||
parse: (d) => toIntOr((d as Map)['updated'], 0),
|
||||
);
|
||||
|
||||
/// 单条标记已读。
|
||||
Future<ApiResponse<void>> markRead(int id) =>
|
||||
_client.put<void>('/worker-app/notices/$id/read');
|
||||
}
|
||||
|
||||
bool _toBool(Object? v) {
|
||||
if (v is bool) return v;
|
||||
if (v is num) return v != 0;
|
||||
final s = v?.toString().toLowerCase();
|
||||
return s == '1' || s == 'true';
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/network/api_response.dart';
|
||||
import '../../../core/providers.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../core/utils/format_util.dart';
|
||||
import '../../../shared/widgets/app_feedback.dart';
|
||||
import '../../../shared/widgets/x_card.dart';
|
||||
import '../../../shared/widgets/x_common.dart';
|
||||
import '../../../shared/widgets/x_top_bar.dart';
|
||||
import '../data/notice_api.dart';
|
||||
|
||||
/// 消息中心:站内通知列表 + 已读标记 + 分页加载。
|
||||
///
|
||||
/// 列表在页面内维护(分页游标语义强于 Riverpod 缓存),未读数经
|
||||
/// [noticeUnreadCountProvider] 统一刷新。
|
||||
class NoticeCenterPage extends ConsumerStatefulWidget {
|
||||
const NoticeCenterPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<NoticeCenterPage> createState() => _NoticeCenterPageState();
|
||||
}
|
||||
|
||||
class _NoticeCenterPageState extends ConsumerState<NoticeCenterPage> {
|
||||
static const _pageSize = 10;
|
||||
|
||||
final _scroll = ScrollController();
|
||||
final _items = <Notice>[];
|
||||
|
||||
int _page = 0;
|
||||
bool _loading = false;
|
||||
bool _loadingMore = false;
|
||||
bool _hasMore = true;
|
||||
String? _error;
|
||||
|
||||
bool get _hasUnread => _items.any((n) => !n.isRead);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scroll.addListener(_onScroll);
|
||||
_load(reset: true);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scroll
|
||||
..removeListener(_onScroll)
|
||||
..dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (!_scroll.hasClients) return;
|
||||
final position = _scroll.position;
|
||||
if (position.pixels >= position.maxScrollExtent - 200) _loadMore();
|
||||
}
|
||||
|
||||
/// 加载一页。`reset` 为真时回到第 1 页(下拉刷新 / 重试)。
|
||||
Future<void> _load({bool reset = false}) async {
|
||||
if (reset) {
|
||||
if (_loading) return;
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
} else {
|
||||
if (_loading || _loadingMore || !_hasMore) return;
|
||||
setState(() => _loadingMore = true);
|
||||
}
|
||||
|
||||
final nextPage = reset ? 1 : _page + 1;
|
||||
try {
|
||||
final res = await ref
|
||||
.read(noticeApiProvider)
|
||||
.list(page: nextPage, pageSize: _pageSize);
|
||||
if (!mounted) return;
|
||||
if (!res.isOk || res.data == null) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_loadingMore = false;
|
||||
if (reset) _error = res.message.isEmpty ? '消息加载失败' : res.message;
|
||||
});
|
||||
return;
|
||||
}
|
||||
final data = res.data!;
|
||||
setState(() {
|
||||
if (reset) _items.clear();
|
||||
_items.addAll(data.list);
|
||||
_page = data.page;
|
||||
_hasMore = data.list.isNotEmpty && _items.length < data.total;
|
||||
_loading = false;
|
||||
_loadingMore = false;
|
||||
_error = null;
|
||||
});
|
||||
} on ApiException catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_loadingMore = false;
|
||||
if (reset) _error = e.message;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMore() => _load();
|
||||
|
||||
Future<void> _markRead(Notice notice) async {
|
||||
if (notice.isRead) return;
|
||||
try {
|
||||
final res = await ref.read(noticeApiProvider).markRead(notice.id);
|
||||
if (!mounted) return;
|
||||
if (!res.isOk) {
|
||||
showAppToast(res.message.isEmpty ? '操作失败' : res.message);
|
||||
return;
|
||||
}
|
||||
final index = _items.indexWhere((n) => n.id == notice.id);
|
||||
if (index >= 0) {
|
||||
setState(() => _items[index] = _items[index].copiedWithRead());
|
||||
}
|
||||
ref.read(noticeUnreadCountProvider.notifier).refresh();
|
||||
} on ApiException catch (e) {
|
||||
if (mounted) showAppToast(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _markAllRead() async {
|
||||
if (!_hasUnread) return;
|
||||
try {
|
||||
final res = await ref.read(noticeApiProvider).readAll();
|
||||
if (!mounted) return;
|
||||
if (!res.isOk) {
|
||||
showAppToast(res.message.isEmpty ? '操作失败' : res.message);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
for (var i = 0; i < _items.length; i++) {
|
||||
if (!_items[i].isRead) _items[i] = _items[i].copiedWithRead();
|
||||
}
|
||||
});
|
||||
showAppToast('已全部标记为已读', success: true);
|
||||
ref.read(noticeUnreadCountProvider.notifier).refresh();
|
||||
} on ApiException catch (e) {
|
||||
if (mounted) showAppToast(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.surfaceContainerLow,
|
||||
body: XPageShell(
|
||||
backgroundColor: AppColors.surfaceContainerLow,
|
||||
topBar: XTopBar.nav(
|
||||
title: '消息中心',
|
||||
onBack: () => context.pop(),
|
||||
trailing: _hasUnread
|
||||
? GestureDetector(
|
||||
onTap: _markAllRead,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 4),
|
||||
child: Text(
|
||||
'全部已读',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
child: _buildBody(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_loading && _items.isEmpty) {
|
||||
return const Center(
|
||||
child: SizedBox(
|
||||
width: 26,
|
||||
height: 26,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.4,
|
||||
valueColor: AlwaysStoppedAnimation(AppColors.primary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_items.isEmpty) {
|
||||
return RefreshIndicator(
|
||||
color: AppColors.primary,
|
||||
onRefresh: () => _load(reset: true),
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [
|
||||
if (_error != null)
|
||||
XPlaceholder(text: '消息加载失败\n$_error')
|
||||
else
|
||||
const XPlaceholder(text: '暂无消息'),
|
||||
if (_error != null)
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: () => _load(reset: true),
|
||||
child: const Text('点击重试'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
color: AppColors.primary,
|
||||
onRefresh: () => _load(reset: true),
|
||||
child: ListView.separated(
|
||||
controller: _scroll,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||
itemCount: _items.length + 1,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == _items.length) return _buildFooter();
|
||||
final notice = _items[index];
|
||||
return _NoticeTile(
|
||||
notice: notice,
|
||||
onTap: notice.isRead ? null : () => _markRead(notice),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooter() {
|
||||
if (_loadingMore) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation(AppColors.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_hasMore) {
|
||||
return GestureDetector(
|
||||
onTap: () => _loadMore(),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'加载更多',
|
||||
style: TextStyle(fontSize: 14, color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'没有更多了',
|
||||
style: TextStyle(fontSize: 13, color: AppColors.textMuted),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条消息卡片:未读有蓝底高亮 + 红点,点击标记已读。
|
||||
class _NoticeTile extends StatelessWidget {
|
||||
const _NoticeTile({required this.notice, this.onTap});
|
||||
|
||||
final Notice notice;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final unread = !notice.isRead;
|
||||
return XCard(
|
||||
padding: const EdgeInsets.all(16),
|
||||
background: unread
|
||||
? AppColors.accentSubtle
|
||||
: AppColors.surfaceContainerLowest,
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (unread)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 7, right: 8),
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.error,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
notice.title.isEmpty ? '系统通知' : notice.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppTypography.bodyLg.copyWith(
|
||||
fontWeight: unread ? FontWeight.w600 : FontWeight.w500,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
formatDateTime(notice.createdAt),
|
||||
style: AppTypography.labelCaps.copyWith(
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (notice.content.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: unread ? 16 : 0),
|
||||
child: Text(
|
||||
notice.content,
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/network/api_response.dart';
|
||||
import '../../../core/providers.dart';
|
||||
import '../data/order_api.dart';
|
||||
import '../data/order_models.dart';
|
||||
|
||||
final orderApiProvider = Provider<OrderApi>(
|
||||
(ref) => OrderApi(ref.watch(apiClientProvider)),
|
||||
);
|
||||
|
||||
/// 订单池(待接订单)。接单成功后由调用方 `invalidate` 触发刷新。
|
||||
final orderPoolProvider = FutureProvider.autoDispose<List<Order>>((ref) async {
|
||||
final res = await ref.watch(orderApiProvider).pool();
|
||||
if (!res.isOk) throw ApiException(res.message);
|
||||
return res.data ?? const [];
|
||||
});
|
||||
|
||||
/// 我的订单,`status` 为 `pending` / `completed` / null(全部)。
|
||||
final myOrdersProvider = FutureProvider.autoDispose
|
||||
.family<List<Order>, String?>((ref, status) async {
|
||||
final res = await ref.watch(orderApiProvider).myOrders(status: status);
|
||||
if (!res.isOk) throw ApiException(res.message);
|
||||
return res.data ?? const [];
|
||||
});
|
||||
|
||||
/// 订单详情。参数为 `(source, id)`。
|
||||
final orderDetailProvider = FutureProvider.autoDispose
|
||||
.family<Order, ({String source, int id})>((ref, arg) async {
|
||||
final res = await ref.watch(orderApiProvider).detail(arg.source, arg.id);
|
||||
if (!res.isOk) throw ApiException(res.message);
|
||||
if (res.data == null) throw const ApiException('订单不存在');
|
||||
return res.data!;
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/network/api_response.dart';
|
||||
import 'order_models.dart';
|
||||
|
||||
/// 订单接口,对应后端 `/api/worker-app/orders*`。
|
||||
class OrderApi {
|
||||
OrderApi(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
/// 订单池:四类未被接单的订单合并。
|
||||
Future<ApiResponse<List<Order>>> pool() => _client.get<List<Order>>(
|
||||
'/worker-app/orders/pool',
|
||||
parse: _parseList,
|
||||
);
|
||||
|
||||
/// 我的订单。`status` 传 `pending` / `completed`,留空取全部。
|
||||
Future<ApiResponse<List<Order>>> myOrders({String? status}) =>
|
||||
_client.get<List<Order>>(
|
||||
'/worker-app/orders',
|
||||
query: {'status': status},
|
||||
parse: _parseList,
|
||||
);
|
||||
|
||||
/// 订单详情。
|
||||
Future<ApiResponse<Order>> detail(String source, int id) =>
|
||||
_client.get<Order>(
|
||||
'/worker-app/orders/$source/$id',
|
||||
parse: (d) => Order.fromJson((d as Map).cast<String, dynamic>()),
|
||||
);
|
||||
|
||||
/// 接单。订单被他人接走时后端返回 409。
|
||||
Future<ApiResponse<void>> grab(String source, int id) =>
|
||||
_client.post<void>('/worker-app/orders/$source/$id/grab');
|
||||
|
||||
/// 完成订单。非本人订单后端返回 403。
|
||||
Future<ApiResponse<void>> complete(String source, int id) =>
|
||||
_client.put<void>('/worker-app/orders/$source/$id/complete');
|
||||
|
||||
static List<Order> _parseList(dynamic data) {
|
||||
final map = (data as Map).cast<String, dynamic>();
|
||||
return (map['list'] as List? ?? const [])
|
||||
.map((e) => Order.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import '../../../core/utils/id_util.dart';
|
||||
|
||||
/// 统一订单视图,对应后端 `WorkerAppService.toOrderView()`。
|
||||
///
|
||||
/// `id` 形如 `repair-12` / `install-5`,`source` 为四类来源之一。
|
||||
class Order {
|
||||
const Order({
|
||||
required this.id,
|
||||
required this.source,
|
||||
required this.status,
|
||||
required this.jobId,
|
||||
required this.title,
|
||||
required this.type,
|
||||
required this.amount,
|
||||
required this.distance,
|
||||
required this.schedule,
|
||||
required this.description,
|
||||
required this.customerName,
|
||||
required this.customerPhone,
|
||||
required this.photos,
|
||||
required this.address,
|
||||
required this.laborFee,
|
||||
required this.materialFee,
|
||||
required this.estimatedDuration,
|
||||
});
|
||||
|
||||
/// `repair-12`
|
||||
final String id;
|
||||
|
||||
/// repair | install | maintenance | survey
|
||||
final String source;
|
||||
|
||||
final int status;
|
||||
|
||||
/// 业务单号(repairNo / installNo / maintenanceNo / surveyNo)
|
||||
final String jobId;
|
||||
|
||||
final String title;
|
||||
|
||||
/// urgent | standard | regular
|
||||
final String type;
|
||||
|
||||
final double amount;
|
||||
final double distance;
|
||||
final String schedule;
|
||||
final String description;
|
||||
final String customerName;
|
||||
final String customerPhone;
|
||||
final List<String> photos;
|
||||
final String address;
|
||||
final double laborFee;
|
||||
final double materialFee;
|
||||
final String estimatedDuration;
|
||||
|
||||
/// 来源中文名,用于收益明细副标题等场景。
|
||||
String get sourceName => switch (source) {
|
||||
'repair' => '家庭电路维修',
|
||||
'install' => '设备安装',
|
||||
'maintenance' => '设备维保',
|
||||
'survey' => '现场勘察',
|
||||
_ => '服务订单',
|
||||
};
|
||||
|
||||
/// 列表页类型标签:紧急呼叫 / 标准预约 / 常规巡检。
|
||||
String get listTypeText => switch (type) {
|
||||
'urgent' => '紧急呼叫',
|
||||
'standard' => '标准预约',
|
||||
_ => '常规巡检',
|
||||
};
|
||||
|
||||
/// 详情页类型标签:紧急 / 标准 / 常规。
|
||||
String get detailTypeText => switch (type) {
|
||||
'urgent' => '紧急',
|
||||
'standard' => '标准',
|
||||
_ => '常规',
|
||||
};
|
||||
|
||||
/// 客户头像占位文字:后端无头像字段,用姓名首字(与「我的」页一致)。
|
||||
String get customerAvatarText => customerName.isEmpty
|
||||
? '客'
|
||||
: String.fromCharCode(customerName.runes.first);
|
||||
|
||||
/// 卡片上展示的订单号:后端 `id` 前缀即来源,取 `jobId` 更贴近源 `#EL-8821` 的观感。
|
||||
String get displayNo => jobId.isNotEmpty ? jobId : id;
|
||||
|
||||
/// 后端接口里的数字主键:`repair-12` → 12。
|
||||
int get intId {
|
||||
final idx = id.lastIndexOf('-');
|
||||
return toIntOr(idx >= 0 ? id.substring(idx + 1) : id, 0);
|
||||
}
|
||||
|
||||
factory Order.fromJson(Map<String, dynamic> json) => Order(
|
||||
id: json['id'] as String? ?? '',
|
||||
source: json['source'] as String? ?? '',
|
||||
status: toIntOr(json['status'], 0),
|
||||
jobId: json['jobId'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
type: json['type'] as String? ?? 'regular',
|
||||
amount: toDoubleOr(json['amount'], 0),
|
||||
distance: toDoubleOr(json['distance'], 0),
|
||||
schedule: json['schedule'] as String? ?? '',
|
||||
description: json['description'] as String? ?? '',
|
||||
customerName: json['customerName'] as String? ?? '',
|
||||
customerPhone: json['customerPhone'] as String? ?? '',
|
||||
photos:
|
||||
(json['photos'] as List?)?.map((e) => e.toString()).toList() ??
|
||||
const [],
|
||||
address: json['address'] as String? ?? '',
|
||||
laborFee: toDoubleOr(json['laborFee'], 0),
|
||||
materialFee: toDoubleOr(json['materialFee'], 0),
|
||||
estimatedDuration: json['estimatedDuration'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,739 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../core/utils/format_util.dart';
|
||||
import '../../../shared/widgets/app_feedback.dart';
|
||||
import '../../../shared/widgets/x_card.dart';
|
||||
import '../../../shared/widgets/x_chip.dart';
|
||||
import '../../../shared/widgets/x_common.dart';
|
||||
import '../../../shared/widgets/x_icon.dart';
|
||||
import '../../../shared/widgets/x_top_bar.dart';
|
||||
import '../application/order_providers.dart';
|
||||
import '../data/order_models.dart';
|
||||
|
||||
/// 金额展示:整数不带小数,否则保留两位。
|
||||
String _money(num v) =>
|
||||
v == v.roundToDouble() ? v.toStringAsFixed(0) : formatMoney(v);
|
||||
|
||||
/// 订单详情页,对应源 `pages/orderdetail/index.vue`。
|
||||
///
|
||||
/// **与源的差异(后端无对应字段)**:客户卡片省略评分与任务数,改为展示电话;
|
||||
/// 服务地点只有一行地址(后端无 `addressDetail`);现场照片为空时只显示「添加」。
|
||||
class OrderDetailPage extends ConsumerWidget {
|
||||
const OrderDetailPage({super.key, required this.source, required this.id});
|
||||
|
||||
/// repair | install | maintenance | survey
|
||||
final String source;
|
||||
final int id;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final detail = ref.watch(orderDetailProvider((source: source, id: id)));
|
||||
|
||||
return Scaffold(
|
||||
body: XPageShell(
|
||||
backgroundColor: AppColors.surfaceContainerLow,
|
||||
topBar: XTopBar.nav(
|
||||
title: '订单详情',
|
||||
onBack: () => context.pop(),
|
||||
trailing: GestureDetector(
|
||||
onTap: () => showAppToast('分享功能'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: XIcon('share', size: 24, color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: switch (detail) {
|
||||
AsyncData(:final value) => _buildContent(context, value),
|
||||
AsyncError(:final error) => XPlaceholder(text: '订单加载失败\n$error'),
|
||||
_ => const Center(
|
||||
child: SizedBox(
|
||||
width: 26,
|
||||
height: 26,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.4,
|
||||
valueColor: AlwaysStoppedAnimation(AppColors.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
// 底部固定操作栏,对应源 `.bottom-action`
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: _BottomAction(
|
||||
onStart: () => showAppToast('已开始出发', success: true),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context, Order order) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: 120),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_MapSection(address: order.address),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_JobCard(order: order),
|
||||
const SizedBox(height: 12),
|
||||
_CustomerCard(order: order),
|
||||
const SizedBox(height: 16),
|
||||
_PhotosSection(photos: order.photos),
|
||||
const SizedBox(height: 16),
|
||||
_FinancialCard(order: order),
|
||||
const SizedBox(height: 16),
|
||||
_LocationCard(address: order.address),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 顶部地图占位(176 高)+ 右下角「导航」按钮。
|
||||
class _MapSection extends StatelessWidget {
|
||||
const _MapSection({required this.address});
|
||||
|
||||
final String address;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 176,
|
||||
child: Stack(
|
||||
children: [
|
||||
const Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFFDDE1FF), Color(0xFFF4F3F8)],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Positioned.fill(
|
||||
child: CustomPaint(painter: _DotPatternPainter()),
|
||||
),
|
||||
Center(
|
||||
child: XIcon(
|
||||
'location',
|
||||
size: 48,
|
||||
color: AppColors.primary.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 12,
|
||||
child: GestureDetector(
|
||||
onTap: () => showAppToast('打开导航'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x4D0052FF),
|
||||
blurRadius: 12,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
XIcon('navigation', size: 20, color: Colors.white),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'导航',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 工单卡片,对应源 `.job-card`。
|
||||
class _JobCard extends StatelessWidget {
|
||||
const _JobCard({required this.order});
|
||||
|
||||
final Order order;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return XCard(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
'JOB ID: #${order.jobId}',
|
||||
style: AppTypography.labelCaps.copyWith(
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
order.title,
|
||||
style: AppTypography.headlineMd.copyWith(
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
XTypeChip(
|
||||
type: order.type,
|
||||
text: order.detailTypeText,
|
||||
compact: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 描述块
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: AppColors.borderSoft),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 2),
|
||||
child: XIcon('warning', size: 20, color: AppColors.error),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'"${order.description}"',
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _MetaItem(label: '预约时间', value: order.schedule),
|
||||
),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 40,
|
||||
color: AppColors.borderSoft,
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: _MetaItem(
|
||||
label: '预计时长',
|
||||
value: order.estimatedDuration,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetaItem extends StatelessWidget {
|
||||
const _MetaItem({required this.label, required this.value});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value.isEmpty ? '-' : value,
|
||||
style: AppTypography.bodyLg.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 客户卡片,对应源 `.customer-card`。
|
||||
class _CustomerCard extends StatelessWidget {
|
||||
const _CustomerCard({required this.order});
|
||||
|
||||
final Order order;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return XCard(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
// 后端无客户头像,用姓名首字占位(与「我的」页头像一致)
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
order.customerAvatarText,
|
||||
style: AppTypography.bodyLg.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
order.customerName.isEmpty ? '未知客户' : order.customerName,
|
||||
style: AppTypography.bodyLg.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
order.customerPhone.isEmpty
|
||||
? '暂无联系方式'
|
||||
: order.customerPhone,
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
GestureDetector(
|
||||
onTap: () => showAppToast('拨打电话'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const XIcon('call', size: 18, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 现场照片横向列表。
|
||||
class _PhotosSection extends StatelessWidget {
|
||||
const _PhotosSection({required this.photos});
|
||||
|
||||
final List<String> photos;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
'现场照片',
|
||||
style: AppTypography.bodySm.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textMuted,
|
||||
letterSpacing: 0.26,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 128,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
for (final url in photos) ...[
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
child: Image.network(
|
||||
url,
|
||||
width: 128,
|
||||
height: 128,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => Container(
|
||||
width: 128,
|
||||
height: 128,
|
||||
color: AppColors.surfaceContainerHigh,
|
||||
child: const XIcon(
|
||||
'camera',
|
||||
size: 28,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
GestureDetector(
|
||||
onTap: () => showAppToast('添加现场照片'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
width: 128,
|
||||
height: 128,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerLowest,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(
|
||||
color: AppColors.textPlaceholder,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: const [
|
||||
XIcon('add-photo', size: 32, color: AppColors.primary),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'添加',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 费用卡片,对应源 `.financial-card`。
|
||||
class _FinancialCard extends StatelessWidget {
|
||||
const _FinancialCard({required this.order});
|
||||
|
||||
final Order order;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return XCard(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Text(
|
||||
'预计收益',
|
||||
style: AppTypography.bodyLg.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
_row('人工费', '¥${_money(order.laborFee)}'),
|
||||
const SizedBox(height: 12),
|
||||
_row('预计材料费', '¥${_money(order.materialFee)}'),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(top: BorderSide(color: AppColors.borderSoft)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'总计预估',
|
||||
style: AppTypography.bodyLg.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'¥${_money(order.amount)}',
|
||||
style: AppTypography.titleLg.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const XIcon('info', size: 18, color: AppColors.textMuted),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'最终金额将根据现场核查和实际材料使用情况而定。',
|
||||
style: AppTypography.labelCaps.copyWith(
|
||||
color: AppColors.textMuted,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(String label, String value) => Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: AppTypography.bodyMd.copyWith(color: AppColors.onSurfaceVariant),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
value,
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 服务地点卡片。
|
||||
class _LocationCard extends StatelessWidget {
|
||||
const _LocationCard({required this.address});
|
||||
|
||||
final String address;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return XCard(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
'服务地点',
|
||||
style: AppTypography.bodySm.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textMuted,
|
||||
letterSpacing: 0.26,
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 2),
|
||||
child: XIcon('location', size: 20, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
address.isEmpty ? '地址待确认' : address,
|
||||
style: AppTypography.bodyLg.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 底部固定「开始出发」按钮。
|
||||
class _BottomAction extends StatelessWidget {
|
||||
const _BottomAction({required this.onStart});
|
||||
|
||||
final VoidCallback onStart;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
16 + MediaQuery.paddingOf(context).bottom,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.headerGlass,
|
||||
border: Border(top: BorderSide(color: AppColors.borderSoft)),
|
||||
),
|
||||
child: GestureDetector(
|
||||
onTap: onStart,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
height: 54,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x4D0052FF),
|
||||
blurRadius: 12,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
XIcon('bolt', size: 24, color: Colors.white),
|
||||
SizedBox(width: 10),
|
||||
Text(
|
||||
'开始出发',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 源 `.map-bg::before` 的圆点纹理(100×100 平铺,品牌蓝 8% 透明度)。
|
||||
class _DotPatternPainter extends CustomPainter {
|
||||
const _DotPatternPainter();
|
||||
|
||||
static const _dots = <(double, double, double)>[
|
||||
(11, 11, 7),
|
||||
(59, 36, 7),
|
||||
(16, 33, 3),
|
||||
(79, 64, 3),
|
||||
(34, 87, 3),
|
||||
(90, 11, 3),
|
||||
(12, 82, 4),
|
||||
(40, 17, 4),
|
||||
(63, 5, 5),
|
||||
(57, 66, 4),
|
||||
(86, 87, 5),
|
||||
(32, 58, 5),
|
||||
(89, 45, 5),
|
||||
(80, 27, 2),
|
||||
(60, 89, 2),
|
||||
(35, 39, 2),
|
||||
(12, 58, 2),
|
||||
];
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()..color = const Color(0x140052FF);
|
||||
for (var ox = 0.0; ox < size.width; ox += 100) {
|
||||
for (var oy = 0.0; oy < size.height; oy += 100) {
|
||||
for (final (cx, cy, r) in _dots) {
|
||||
canvas.drawCircle(Offset(ox + cx, oy + cy), r, paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/network/api_response.dart';
|
||||
import '../../../core/providers.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../core/utils/format_util.dart';
|
||||
import '../../../shared/widgets/app_feedback.dart';
|
||||
import '../../../shared/widgets/x_chip.dart';
|
||||
import '../../../shared/widgets/x_common.dart';
|
||||
import '../../../shared/widgets/x_icon.dart';
|
||||
import '../../../shared/widgets/x_top_bar.dart';
|
||||
import '../application/order_providers.dart';
|
||||
import '../data/order_models.dart';
|
||||
|
||||
/// 金额展示:整数不带小数,否则保留两位(源 mock 里金额是整数)。
|
||||
String _money(num v) =>
|
||||
v == v.roundToDouble() ? v.toStringAsFixed(0) : formatMoney(v);
|
||||
|
||||
/// 订单 Tab(待接订单),对应源 `pages/order/index.vue`。
|
||||
///
|
||||
/// 顶栏与地图区固定,只有下方订单列表滚动;沿用源 `.orders-section`
|
||||
/// 24px 顶部圆角「上移压住地图」的观感(把地图高度相应减 24,视觉等价)。
|
||||
class OrderTab extends ConsumerStatefulWidget {
|
||||
const OrderTab({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<OrderTab> createState() => _OrderTabState();
|
||||
}
|
||||
|
||||
class _OrderTabState extends ConsumerState<OrderTab> {
|
||||
/// 本会话内已接单的订单 id(接单后列表会刷新移除,这里用于渲染「已接单」态)。
|
||||
final _grabbed = <String>{};
|
||||
String? _grabbing;
|
||||
|
||||
Future<void> _handleGrab(Order order) async {
|
||||
if (_grabbing != null || _grabbed.contains(order.id)) return;
|
||||
setState(() => _grabbing = order.id);
|
||||
try {
|
||||
final res = await ref
|
||||
.read(orderApiProvider)
|
||||
.grab(order.source, order.intId);
|
||||
if (!mounted) return;
|
||||
if (res.isOk) {
|
||||
setState(() => _grabbed.add(order.id));
|
||||
showAppToast('接单成功', success: true);
|
||||
ref.invalidate(orderPoolProvider);
|
||||
} else {
|
||||
showAppToast(res.message.isEmpty ? '接单失败' : res.message);
|
||||
}
|
||||
} on ApiException catch (e) {
|
||||
if (mounted) showAppToast(e.message);
|
||||
} finally {
|
||||
if (mounted) setState(() => _grabbing = null);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pool = ref.watch(orderPoolProvider);
|
||||
final unread = ref.watch(noticeUnreadCountProvider);
|
||||
|
||||
return XPageShell(
|
||||
topBar: XTopBar.brand(
|
||||
height: 64,
|
||||
logoSize: 28,
|
||||
titleSize: 20,
|
||||
titleWeight: FontWeight.w700,
|
||||
trailingBadge: unread,
|
||||
onTrailingTap: () => context.push('/notice'),
|
||||
),
|
||||
child: RefreshIndicator(
|
||||
color: AppColors.primary,
|
||||
onRefresh: () async {
|
||||
ref.invalidate(orderPoolProvider);
|
||||
await ref.read(orderPoolProvider.future);
|
||||
},
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [
|
||||
// 源地图高 320,被下方 24px 圆角区块遮住底部 → 这里直接取 296
|
||||
SizedBox(height: 296, child: const _MapPlaceholder()),
|
||||
Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.bgPage,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(AppRadius.xxl),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 120),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildSectionHeader(pool),
|
||||
const SizedBox(height: 16),
|
||||
..._buildBody(pool),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(AsyncValue<List<Order>> pool) {
|
||||
final count = pool.value?.length;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'待接订单',
|
||||
style: AppTypography.navLargeTitle.copyWith(
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
count == null ? '正在获取附近订单…' : '附近有 $count 个订单',
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => showAppToast('筛选功能'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
'筛选',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const XIcon('filter', size: 18, color: AppColors.primary),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildBody(AsyncValue<List<Order>> pool) {
|
||||
if (pool.isLoading && !pool.hasValue) {
|
||||
return const [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 60),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 26,
|
||||
height: 26,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.4,
|
||||
valueColor: AlwaysStoppedAnimation(AppColors.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
if (pool.hasError && !pool.hasValue) {
|
||||
return [
|
||||
XPlaceholder(text: '订单加载失败\n${pool.error}'),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: () => ref.invalidate(orderPoolProvider),
|
||||
child: const Text('点击重试'),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
final orders = pool.value ?? const <Order>[];
|
||||
if (orders.isEmpty) {
|
||||
return const [XPlaceholder(text: '附近暂无待接订单')];
|
||||
}
|
||||
return [
|
||||
for (final order in orders)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: _OrderCard(
|
||||
order: order,
|
||||
grabbing: _grabbing == order.id,
|
||||
grabbed: _grabbed.contains(order.id),
|
||||
onGrab: () => _handleGrab(order),
|
||||
onTap: () => context.push('/order/${order.source}/${order.intId}'),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// 单张待接订单卡片,对应源 `.order-card`。
|
||||
class _OrderCard extends StatelessWidget {
|
||||
const _OrderCard({
|
||||
required this.order,
|
||||
required this.grabbing,
|
||||
required this.grabbed,
|
||||
required this.onGrab,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final Order order;
|
||||
final bool grabbing;
|
||||
final bool grabbed;
|
||||
final VoidCallback onGrab;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: AppColors.surfaceContainerLowest,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppColors.borderSoft),
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
boxShadow: AppShadows.orderCard,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'#${order.displayNo}',
|
||||
style: AppTypography.labelCaps.copyWith(
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
XTypeChip(type: order.type, text: order.listTypeText),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'¥${_money(order.amount)}',
|
||||
style: AppTypography.headlineMd.copyWith(
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'预估收益',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
order.title,
|
||||
style: AppTypography.headlineMd.copyWith(
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const XIcon(
|
||||
'location',
|
||||
size: 16,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'${order.distance}km',
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
const XIcon(
|
||||
'clock',
|
||||
size: 16,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
order.schedule,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildGrabButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGrabButton() {
|
||||
final (Color bg, Color fg, BoxShadow? shadow, Widget icon) = switch ((
|
||||
grabbing,
|
||||
grabbed,
|
||||
order.type,
|
||||
)) {
|
||||
(true, _, _) => (
|
||||
AppColors.primary,
|
||||
Colors.white,
|
||||
_primaryShadow,
|
||||
const _SpinningRefresh(),
|
||||
),
|
||||
(_, true, _) => (
|
||||
AppColors.tertiaryFixed,
|
||||
AppColors.onTertiaryFixedVariant,
|
||||
null,
|
||||
const XIcon('check', size: 20, color: AppColors.onTertiaryFixedVariant),
|
||||
),
|
||||
(_, _, 'urgent') => (
|
||||
AppColors.primary,
|
||||
Colors.white,
|
||||
_primaryShadow,
|
||||
const XIcon('forward', size: 20, color: Colors.white),
|
||||
),
|
||||
_ => (
|
||||
AppColors.surfaceContainerLow,
|
||||
AppColors.onSurface,
|
||||
null,
|
||||
const XIcon('forward', size: 20, color: AppColors.onSurface),
|
||||
),
|
||||
};
|
||||
|
||||
final label = grabbing
|
||||
? '接单中...'
|
||||
: grabbed
|
||||
? '已接单'
|
||||
: (order.type == 'urgent' ? '立即接单' : '接单');
|
||||
final outlined = !grabbing && !grabbed && order.type != 'urgent';
|
||||
|
||||
return Opacity(
|
||||
opacity: grabbing ? 0.7 : 1,
|
||||
child: GestureDetector(
|
||||
onTap: (grabbing || grabbed) ? null : onGrab,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
height: 48,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: outlined
|
||||
? Border.all(color: const Color(0x80C3C5D9))
|
||||
: null,
|
||||
boxShadow: shadow == null ? null : [shadow],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
icon,
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: fg,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static const _primaryShadow = BoxShadow(
|
||||
color: Color(0x4D0052FF),
|
||||
blurRadius: 12,
|
||||
offset: Offset(0, 4),
|
||||
);
|
||||
}
|
||||
|
||||
/// 接单中的旋转刷新图标。
|
||||
class _SpinningRefresh extends StatefulWidget {
|
||||
const _SpinningRefresh();
|
||||
|
||||
@override
|
||||
State<_SpinningRefresh> createState() => _SpinningRefreshState();
|
||||
}
|
||||
|
||||
class _SpinningRefreshState extends State<_SpinningRefresh>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _c = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 1),
|
||||
)..repeat();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_c.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => RotationTransition(
|
||||
turns: _c,
|
||||
child: const XIcon('refresh', size: 20, color: Colors.white),
|
||||
);
|
||||
}
|
||||
|
||||
/// 地图占位:源 `.map-bg` 的圆点纹理 + 两个标记 + 右侧控制按钮。
|
||||
class _MapPlaceholder extends StatelessWidget {
|
||||
const _MapPlaceholder();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
const Positioned.fill(
|
||||
child: ColoredBox(color: AppColors.surfaceContainerHighest),
|
||||
),
|
||||
const Positioned.fill(child: CustomPaint(painter: _DotPatternPainter())),
|
||||
// 紧急标记(带脉冲光环)
|
||||
const Positioned(
|
||||
top: 40,
|
||||
left: 60,
|
||||
child: _PulsingMarker(),
|
||||
),
|
||||
// 普通标记
|
||||
Positioned(
|
||||
top: 120,
|
||||
right: 80,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x1A000000),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const XIcon('shandian', size: 18, color: Colors.white),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 48,
|
||||
child: Column(
|
||||
children: [
|
||||
_MapControlButton(
|
||||
icon: 'location',
|
||||
onTap: () => showAppToast('定位中...'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_MapControlButton(
|
||||
icon: 'map',
|
||||
onTap: () => showAppToast('图层切换'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 紧急标记:红色胶囊 + 呼吸光环,对应源 `.marker.urgent`。
|
||||
class _PulsingMarker extends StatefulWidget {
|
||||
const _PulsingMarker();
|
||||
|
||||
@override
|
||||
State<_PulsingMarker> createState() => _PulsingMarkerState();
|
||||
}
|
||||
|
||||
class _PulsingMarkerState extends State<_PulsingMarker>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _c = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 2),
|
||||
)..repeat(reverse: true);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_c.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
animation: _c,
|
||||
builder: (context, _) => Container(
|
||||
width: 76 + 24 * _c.value,
|
||||
height: 32 + 24 * _c.value,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.secondaryContainer.withValues(
|
||||
alpha: 0.4 * (1 - _c.value),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x1A000000),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
XIcon('shandian', size: 14, color: Colors.white),
|
||||
SizedBox(width: 6),
|
||||
Text(
|
||||
'紧急',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MapControlButton extends StatelessWidget {
|
||||
const _MapControlButton({required this.icon, required this.onTap});
|
||||
|
||||
final String icon;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xE6FFFFFF),
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x14000000),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: XIcon(icon, size: 20, color: AppColors.primary),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 源 `.map-bg::before` 的圆点纹理(100×100 平铺,品牌蓝 8% 透明度)。
|
||||
class _DotPatternPainter extends CustomPainter {
|
||||
const _DotPatternPainter();
|
||||
|
||||
/// 取自源 SVG 里的 (cx, cy, r) 列表。
|
||||
static const _dots = <(double, double, double)>[
|
||||
(11, 11, 7),
|
||||
(59, 36, 7),
|
||||
(16, 33, 3),
|
||||
(79, 64, 3),
|
||||
(34, 87, 3),
|
||||
(90, 11, 3),
|
||||
(12, 82, 4),
|
||||
(40, 17, 4),
|
||||
(63, 5, 5),
|
||||
(57, 66, 4),
|
||||
(86, 87, 5),
|
||||
(32, 58, 5),
|
||||
(89, 45, 5),
|
||||
(80, 27, 2),
|
||||
(60, 89, 2),
|
||||
(35, 39, 2),
|
||||
(12, 58, 2),
|
||||
];
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()..color = const Color(0x140052FF);
|
||||
for (var ox = 0.0; ox < size.width; ox += 100) {
|
||||
for (var oy = 0.0; oy < size.height; oy += 100) {
|
||||
for (final (cx, cy, r) in _dots) {
|
||||
canvas.drawCircle(Offset(ox + cx, oy + cy), r, paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/network/api_response.dart';
|
||||
import '../../../core/providers.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../shared/widgets/app_feedback.dart';
|
||||
import '../../../shared/widgets/x_card.dart';
|
||||
import '../../../shared/widgets/x_common.dart';
|
||||
import '../../../shared/widgets/x_icon.dart';
|
||||
import '../../../shared/widgets/x_top_bar.dart';
|
||||
import '../../auth/application/session_controller.dart';
|
||||
import '../../auth/data/auth_models.dart';
|
||||
|
||||
/// 渐变卡内各级白色透明度,对应源 `rgba(255,255,255,x)`。
|
||||
const _white80 = Color(0xCCFFFFFF);
|
||||
const _white60 = Color(0x99FFFFFF);
|
||||
const _white20 = Color(0x33FFFFFF);
|
||||
const _white15 = Color(0x26FFFFFF);
|
||||
const _white10 = Color(0x1AFFFFFF);
|
||||
|
||||
/// 个人资料接口。后端未提供独立 provider,故在此局部声明。
|
||||
/// 源页面「我的」只展示一张证书,这里取 `WorkerProfile.primaryCert`。
|
||||
final _profileProvider = FutureProvider.autoDispose<WorkerProfile>((ref) async {
|
||||
final res = await ref.watch(authApiProvider).profile();
|
||||
if (!res.isOk || res.data == null) throw ApiException(res.message);
|
||||
return res.data!;
|
||||
});
|
||||
|
||||
/// 「我的」Tab,逐像素复刻源 `src/pages/profile/index.vue`。
|
||||
///
|
||||
/// 顶栏由本页渲染;底部 Tab 栏由 `TabsPage` 提供。
|
||||
/// 「专业技能」区块与「从业年限」统计项后端无对应字段,已按约定省略。
|
||||
class ProfileTab extends ConsumerWidget {
|
||||
const ProfileTab({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final session = ref.watch(sessionProvider);
|
||||
final profileAsync = ref.watch(_profileProvider);
|
||||
final unread = ref.watch(noticeUnreadCountProvider);
|
||||
final profile = profileAsync.value;
|
||||
final worker = profile?.basic ?? session.worker;
|
||||
|
||||
return XPageShell(
|
||||
topBar: XTopBar.brand(
|
||||
height: 56,
|
||||
logoSize: 28,
|
||||
titleSize: 18,
|
||||
titleWeight: FontWeight.w600,
|
||||
trailingBadge: unread,
|
||||
onTrailingTap: () => context.push('/notice'),
|
||||
),
|
||||
child: worker == null
|
||||
? _placeholder(profileAsync)
|
||||
: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.only(bottom: 96),
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 24, 16, 24),
|
||||
child: XPageTitle(
|
||||
title: '我的',
|
||||
subtitle: '管理您的个人资料与账户设置',
|
||||
),
|
||||
),
|
||||
_profileCard(worker, profile?.primaryCert),
|
||||
_menuSection(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: _logoutButton(ref),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 无缓存资料时展示加载 / 错误态。
|
||||
Widget _placeholder(AsyncValue<WorkerProfile> async) {
|
||||
final text = async.when(
|
||||
loading: () => '加载中...',
|
||||
error: (error, _) => error.toString(),
|
||||
data: (_) => '加载中...',
|
||||
);
|
||||
return ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [XPlaceholder(text: text)],
|
||||
);
|
||||
}
|
||||
|
||||
/// 资料卡:渐变底 + 头像 + 姓名 / ID + 证书区 + 两项统计。
|
||||
Widget _profileCard(WorkerInfo worker, CertInfo? cert) {
|
||||
final certType = cert?.certType ?? '';
|
||||
final certTitle = certType.isEmpty ? '电工职业资格证' : certType;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [AppColors.primaryDeep, AppColors.primary],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 64,
|
||||
height: 64,
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.onPrimary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
worker.avatarText,
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 2,
|
||||
bottom: 2,
|
||||
child: Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.success,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: AppColors.primary,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
worker.displayName,
|
||||
style: AppTypography.titleLg.copyWith(
|
||||
color: AppColors.onPrimary,
|
||||
letterSpacing: -0.44,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _white20,
|
||||
borderRadius: BorderRadius.circular(AppRadius.regular),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'ID',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: _white80,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'${worker.id}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.onPrimary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: _white15,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const XIcon('cert', size: 18, color: AppColors.onPrimary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
certTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
_certBadge(worker),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (cert == null)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(
|
||||
'暂无资质证照',
|
||||
style: TextStyle(fontSize: 13, color: _white60),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
_certRow('证书编号', cert.certNo),
|
||||
_certRow('有效期至', cert.expireDate, divider: false),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _statItem('${worker.completedCount}', '已完成订单')),
|
||||
Container(width: 1, height: 40, color: _white20),
|
||||
Expanded(
|
||||
child: _statItem(worker.rating.toStringAsFixed(1), '评分'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 审核通过显示绿色「已认证」,否则以弱化样式显示审核状态文案。
|
||||
Widget _certBadge(WorkerInfo worker) {
|
||||
final approved = worker.isApproved;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: approved ? AppColors.success : _white20,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
approved ? '已认证' : worker.auditStatusText,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: approved ? AppColors.onPrimary : _white80,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _certRow(String label, String value, {bool divider = true}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: divider ? const Border(bottom: BorderSide(color: _white10)) : null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontSize: 13, color: _white80)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _statItem(String value, String label) => Column(
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.onPrimary,
|
||||
letterSpacing: -0.48,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(label, style: const TextStyle(fontSize: 12, color: _white80)),
|
||||
],
|
||||
);
|
||||
|
||||
/// 两组 `ios-card` 菜单,行点击仅提示(同源实现)。
|
||||
Widget _menuSection() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||
child: Column(
|
||||
children: [
|
||||
_menuGroup(const [
|
||||
('order', '我的订单'),
|
||||
('earnings', '收益记录'),
|
||||
('notify', '账户设置'),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
_menuGroup(const [('star', '帮助中心'), ('shandian', '关于我们')]),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _menuGroup(List<(String, String)> items) {
|
||||
return XCard(
|
||||
radius: AppRadius.lg,
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < items.length; i++) ...[
|
||||
if (i > 0)
|
||||
Container(
|
||||
height: 1,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
color: AppColors.borderSoft,
|
||||
),
|
||||
_menuRow(items[i].$1, items[i].$2),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _menuRow(String icon, String label) {
|
||||
return GestureDetector(
|
||||
onTap: () => showAppToast(label),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
XIcon(icon, size: 22, color: AppColors.onSurfaceVariant),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: AppTypography.bodyLg.copyWith(
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
const XIcon(
|
||||
'arrow-right',
|
||||
size: 16,
|
||||
color: AppColors.textPlaceholder,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _logoutButton(WidgetRef ref) {
|
||||
return XCard(
|
||||
radius: AppRadius.lg,
|
||||
padding: EdgeInsets.zero,
|
||||
onTap: () => _handleLogout(ref),
|
||||
child: const SizedBox(
|
||||
height: 48,
|
||||
width: double.infinity,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'退出登录',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.tertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleLogout(WidgetRef ref) async {
|
||||
final confirmed = await showAppConfirm(
|
||||
title: '提示',
|
||||
content: '确定要退出登录吗?',
|
||||
confirmText: '确定',
|
||||
cancelText: '取消',
|
||||
);
|
||||
if (!confirmed) return;
|
||||
await ref.read(sessionProvider.notifier).logout();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'app/app.dart';
|
||||
import 'core/providers.dart';
|
||||
import 'core/storage/token_storage.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// SharedPreferences 必须在 runApp 之前完成异步读取,再通过 override 注入。
|
||||
final storage = await TokenStorage.open();
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
overrides: [tokenStorageProvider.overrideWithValue(storage)],
|
||||
child: const VoltLinkApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/app_keys.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.dart';
|
||||
import '../../core/theme/app_typography.dart';
|
||||
|
||||
/// `uni.showToast({ title, icon })` 的等价实现。
|
||||
/// 不依赖调用方的 BuildContext,可在任意异步回调里安全使用。
|
||||
void showAppToast(String title, {bool success = false}) {
|
||||
final messenger = AppKeys.scaffoldMessenger.currentState;
|
||||
if (messenger == null) return;
|
||||
messenger
|
||||
..clearSnackBars()
|
||||
..showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
if (success) ...[
|
||||
const Icon(
|
||||
Icons.check_circle,
|
||||
size: 18,
|
||||
color: AppColors.success,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: AppTypography.bodyMd.copyWith(color: AppColors.surface),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: AppColors.inverseSurface,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(AppSpacing.md),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
duration: Duration(milliseconds: success ? 1500 : 2200),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// `uni.showLoading({ title })` 的等价实现,返回关闭函数。
|
||||
VoidCallback showAppLoading([String title = '加载中...']) {
|
||||
final navigator = AppKeys.rootNavigator.currentState;
|
||||
if (navigator == null) return () {};
|
||||
var closed = false;
|
||||
showDialog<void>(
|
||||
context: navigator.context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => PopScope(
|
||||
canPop: false,
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xCC000000),
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 26,
|
||||
height: 26,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.4,
|
||||
valueColor: AlwaysStoppedAnimation(Colors.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
title,
|
||||
style: AppTypography.bodyMd.copyWith(color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return () {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
final current = AppKeys.rootNavigator.currentState;
|
||||
if (current != null && current.canPop()) current.pop();
|
||||
};
|
||||
}
|
||||
|
||||
/// `uni.showModal({ title, content })` 的等价实现,返回用户是否点了确认。
|
||||
Future<bool> showAppConfirm({
|
||||
required String title,
|
||||
required String content,
|
||||
String confirmText = '确定',
|
||||
String cancelText = '取消',
|
||||
bool destructive = false,
|
||||
}) async {
|
||||
final navigator = AppKeys.rootNavigator.currentState;
|
||||
if (navigator == null) return false;
|
||||
final result = await showDialog<bool>(
|
||||
context: navigator.context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: AppColors.surfaceContainerLowest,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
),
|
||||
title: Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTypography.bodyLg.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
content: Text(
|
||||
content,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTypography.bodyMd.copyWith(color: AppColors.textMuted),
|
||||
),
|
||||
actionsAlignment: MainAxisAlignment.spaceEvenly,
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: Text(
|
||||
cancelText,
|
||||
style: AppTypography.bodyLg.copyWith(color: AppColors.textMuted),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: Text(
|
||||
confirmText,
|
||||
style: AppTypography.bodyLg.copyWith(
|
||||
color: destructive ? AppColors.tertiaryContainer : AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||