Commit 11c8fd0072739621874790b6d1a54c65951b67fb

Authored by 常守达
1 parent aee3f374

feat(my): 我的

Showing 50 changed files with 2749 additions and 381 deletions
@@ -4,9 +4,11 @@ import android.content.Intent @@ -4,9 +4,11 @@ import android.content.Intent
4 import androidx.activity.result.contract.ActivityResultContracts 4 import androidx.activity.result.contract.ActivityResultContracts
5 import com.doublefeel.app.native.AlipayHostApiImpl 5 import com.doublefeel.app.native.AlipayHostApiImpl
6 import com.doublefeel.app.native.HealthKitHostApiImpl 6 import com.doublefeel.app.native.HealthKitHostApiImpl
  7 +import com.doublefeel.app.native.PlatformHostApiImpl
7 import com.doublefeel.app.native.WearEngineHostApiImpl 8 import com.doublefeel.app.native.WearEngineHostApiImpl
8 import com.doublefeel.app.pigeon.HealthKitHostApi 9 import com.doublefeel.app.pigeon.HealthKitHostApi
9 import com.doublefeel.app.pigeon.alipay.AlipayHostApi 10 import com.doublefeel.app.pigeon.alipay.AlipayHostApi
  11 +import com.doublefeel.app.pigeon.platform.PlatformHostApi
10 import com.doublefeel.app.pigeon.wear.WearEngineHostApi 12 import com.doublefeel.app.pigeon.wear.WearEngineHostApi
11 import io.flutter.embedding.android.FlutterFragmentActivity 13 import io.flutter.embedding.android.FlutterFragmentActivity
12 import io.flutter.embedding.engine.FlutterEngine 14 import io.flutter.embedding.engine.FlutterEngine
@@ -33,6 +35,7 @@ class MainActivity : FlutterFragmentActivity() { @@ -33,6 +35,7 @@ class MainActivity : FlutterFragmentActivity() {
33 HealthKitHostApi.setUp(messenger, HealthKitHostApiImpl(this)) 35 HealthKitHostApi.setUp(messenger, HealthKitHostApiImpl(this))
34 WearEngineHostApi.setUp(messenger, WearEngineHostApiImpl(applicationContext)) 36 WearEngineHostApi.setUp(messenger, WearEngineHostApiImpl(applicationContext))
35 AlipayHostApi.setUp(messenger, AlipayHostApiImpl(this)) 37 AlipayHostApi.setUp(messenger, AlipayHostApiImpl(this))
  38 + PlatformHostApi.setUp(messenger, PlatformHostApiImpl(application))
36 } 39 }
37 40
38 override fun onDestroy() { 41 override fun onDestroy() {
  1 +package com.doublefeel.app.native
  2 +
  3 +import android.content.pm.PackageManager
  4 +import android.content.res.Resources
  5 +import android.os.Build
  6 +import android.webkit.WebSettings
  7 +import com.doublefeel.app.pigeon.platform.PlatformHostApi
  8 +import kotlin.math.max
  9 +import kotlin.math.min
  10 +
  11 +/**
  12 + * PlatformApi host implementation for Pigeon.
  13 + *
  14 + * 组装完整 User-Agent,格式与 Android 端 DeviceInfoUtils.userAgent 完全一致:
  15 + * `{systemWebViewUA} doublefeel/{versionCode}({versionName})({manufacturer}##{brand}##{model}; Android{sdkInt}; {height}x{width})(huawei)`
  16 + */
  17 +class PlatformHostApiImpl(private val application: android.app.Application) : PlatformHostApi {
  18 +
  19 + override fun getFullUserAgent(): String {
  20 + val systemUa = runCatching {
  21 + WebSettings.getDefaultUserAgent(application)
  22 + }.getOrDefault("")
  23 +
  24 + // 版本信息
  25 + val versionInfo = runCatching {
  26 + val pkg = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
  27 + application.packageManager.getPackageInfo(application.packageName, 0)
  28 + } else {
  29 + application.packageManager.getPackageInfo(
  30 + application.packageName,
  31 + PackageManager.PackageInfoFlags.of(0)
  32 + )
  33 + }
  34 + Pair(pkg.longVersionCode, pkg.versionName ?: "")
  35 + }.getOrDefault(Pair(0L, ""))
  36 +
  37 + val versionCode = versionInfo.first
  38 + val versionName = versionInfo.second
  39 +
  40 + // 设备信息(对应 Android Build.*)
  41 + val manufacturer = Build.MANUFACTURER ?: ""
  42 + val brand = Build.BRAND ?: ""
  43 + val model = Build.MODEL ?: ""
  44 + val sdkInt = Build.VERSION.SDK_INT
  45 +
  46 + // 屏幕物理分辨率:长边为 height,短边为 width
  47 + val dm = Resources.getSystem().displayMetrics
  48 + val screenWidth = min(dm.widthPixels, dm.heightPixels)
  49 + val screenHeight = max(dm.widthPixels, dm.heightPixels)
  50 +
  51 + val customAgent = "doublefeel/$versionCode($versionName)" +
  52 + "($manufacturer##$brand##$model; Android$sdkInt; ${screenHeight}x$screenWidth)" +
  53 + "(android)"
  54 +
  55 + return "${systemUa.trim()} $customAgent".trim()
  56 + }
  57 +}
  1 +// // Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Autogenerated from Pigeon (v25.5.0), do not edit directly.
  3 +// See also: https://pub.dev/packages/pigeon
  4 +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
  5 +
  6 +package com.doublefeel.app.pigeon.platform
  7 +
  8 +import android.util.Log
  9 +import io.flutter.plugin.common.BasicMessageChannel
  10 +import io.flutter.plugin.common.BinaryMessenger
  11 +import io.flutter.plugin.common.EventChannel
  12 +import io.flutter.plugin.common.MessageCodec
  13 +import io.flutter.plugin.common.StandardMethodCodec
  14 +import io.flutter.plugin.common.StandardMessageCodec
  15 +import java.io.ByteArrayOutputStream
  16 +import java.nio.ByteBuffer
  17 +private object PlatformApiPigeonUtils {
  18 +
  19 + fun wrapResult(result: Any?): List<Any?> {
  20 + return listOf(result)
  21 + }
  22 +
  23 + fun wrapError(exception: Throwable): List<Any?> {
  24 + return if (exception is FlutterError) {
  25 + listOf(
  26 + exception.code,
  27 + exception.message,
  28 + exception.details
  29 + )
  30 + } else {
  31 + listOf(
  32 + exception.javaClass.simpleName,
  33 + exception.toString(),
  34 + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
  35 + )
  36 + }
  37 + }
  38 +}
  39 +
  40 +/**
  41 + * Error class for passing custom error details to Flutter via a thrown PlatformException.
  42 + * @property code The error code.
  43 + * @property message The error message.
  44 + * @property details The error details. Must be a datatype supported by the api codec.
  45 + */
  46 +class FlutterError (
  47 + val code: String,
  48 + override val message: String? = null,
  49 + val details: Any? = null
  50 +) : Throwable()
  51 +private open class PlatformApiPigeonCodec : StandardMessageCodec() {
  52 + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
  53 + return super.readValueOfType(type, buffer)
  54 + }
  55 + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
  56 + super.writeValue(stream, value)
  57 + }
  58 +}
  59 +
  60 +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
  61 +interface PlatformHostApi {
  62 + /**
  63 + * 返回完整的 User-Agent 字符串,由 native 侧组装:
  64 + * `{systemWebViewUA} {appName}/{versionCode}({versionName})({manufacturer}##{brand}##{model}; {OS}{osVersion}; {height}x{width})(huawei)`
  65 + */
  66 + fun getFullUserAgent(): String
  67 +
  68 + companion object {
  69 + /** The codec used by PlatformHostApi. */
  70 + val codec: MessageCodec<Any?> by lazy {
  71 + PlatformApiPigeonCodec()
  72 + }
  73 + /** Sets up an instance of `PlatformHostApi` to handle messages through the `binaryMessenger`. */
  74 + @JvmOverloads
  75 + fun setUp(binaryMessenger: BinaryMessenger, api: PlatformHostApi?, messageChannelSuffix: String = "") {
  76 + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
  77 + run {
  78 + val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.doublefeel_flutter.PlatformHostApi.getFullUserAgent$separatedMessageChannelSuffix", codec)
  79 + if (api != null) {
  80 + channel.setMessageHandler { _, reply ->
  81 + val wrapped: List<Any?> = try {
  82 + listOf(api.getFullUserAgent())
  83 + } catch (exception: Throwable) {
  84 + PlatformApiPigeonUtils.wrapError(exception)
  85 + }
  86 + reply.reply(wrapped)
  87 + }
  88 + } else {
  89 + channel.setMessageHandler(null)
  90 + }
  91 + }
  92 + }
  93 + }
  94 +}
No preview for this file type
No preview for this file type
No preview for this file type
@@ -39,6 +39,7 @@ enum NativePigeonRegistrar { @@ -39,6 +39,7 @@ enum NativePigeonRegistrar {
39 HealthKitHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: HealthKitHostApiStub()) 39 HealthKitHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: HealthKitHostApiStub())
40 WearEngineHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: WearEngineHostApiStub()) 40 WearEngineHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: WearEngineHostApiStub())
41 AlipayHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: AlipayHostApiStub()) 41 AlipayHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: AlipayHostApiStub())
  42 + PlatformHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: PlatformHostApiImpl())
42 } 43 }
43 } 44 }
44 45
  1 +// // Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Autogenerated from Pigeon (v25.5.0), do not edit directly.
  3 +// See also: https://pub.dev/packages/pigeon
  4 +
  5 +import Foundation
  6 +
  7 +#if os(iOS)
  8 + import Flutter
  9 +#elseif os(macOS)
  10 + import FlutterMacOS
  11 +#else
  12 + #error("Unsupported platform.")
  13 +#endif
  14 +
  15 +
  16 +private func wrapResult(_ result: Any?) -> [Any?] {
  17 + return [result]
  18 +}
  19 +
  20 +private func wrapError(_ error: Any) -> [Any?] {
  21 + if let pigeonError = error as? PigeonError {
  22 + return [
  23 + pigeonError.code,
  24 + pigeonError.message,
  25 + pigeonError.details,
  26 + ]
  27 + }
  28 + if let flutterError = error as? FlutterError {
  29 + return [
  30 + flutterError.code,
  31 + flutterError.message,
  32 + flutterError.details,
  33 + ]
  34 + }
  35 + return [
  36 + "\(error)",
  37 + "\(type(of: error))",
  38 + "Stacktrace: \(Thread.callStackSymbols)",
  39 + ]
  40 +}
  41 +
  42 +private func isNullish(_ value: Any?) -> Bool {
  43 + return value is NSNull || value == nil
  44 +}
  45 +
  46 +private func nilOrValue<T>(_ value: Any?) -> T? {
  47 + if value is NSNull { return nil }
  48 + return value as! T?
  49 +}
  50 +
  51 +
  52 +private class PlatformApiPigeonCodecReader: FlutterStandardReader {
  53 +}
  54 +
  55 +private class PlatformApiPigeonCodecWriter: FlutterStandardWriter {
  56 +}
  57 +
  58 +private class PlatformApiPigeonCodecReaderWriter: FlutterStandardReaderWriter {
  59 + override func reader(with data: Data) -> FlutterStandardReader {
  60 + return PlatformApiPigeonCodecReader(data: data)
  61 + }
  62 +
  63 + override func writer(with data: NSMutableData) -> FlutterStandardWriter {
  64 + return PlatformApiPigeonCodecWriter(data: data)
  65 + }
  66 +}
  67 +
  68 +class PlatformApiPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
  69 + static let shared = PlatformApiPigeonCodec(readerWriter: PlatformApiPigeonCodecReaderWriter())
  70 +}
  71 +
  72 +/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
  73 +protocol PlatformHostApi {
  74 + /// 返回完整的 User-Agent 字符串,由 native 侧组装:
  75 + /// `{systemWebViewUA} {appName}/{versionCode}({versionName})({manufacturer}##{brand}##{model}; {OS}{osVersion}; {height}x{width})(huawei)`
  76 + func getFullUserAgent() throws -> String
  77 +}
  78 +
  79 +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
  80 +class PlatformHostApiSetup {
  81 + static var codec: FlutterStandardMessageCodec { PlatformApiPigeonCodec.shared }
  82 + /// Sets up an instance of `PlatformHostApi` to handle messages through the `binaryMessenger`.
  83 + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: PlatformHostApi?, messageChannelSuffix: String = "") {
  84 + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
  85 + /// 返回完整的 User-Agent 字符串,由 native 侧组装:
  86 + /// `{systemWebViewUA} {appName}/{versionCode}({versionName})({manufacturer}##{brand}##{model}; {OS}{osVersion}; {height}x{width})(huawei)`
  87 + let getFullUserAgentChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.PlatformHostApi.getFullUserAgent\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
  88 + if let api = api {
  89 + getFullUserAgentChannel.setMessageHandler { _, reply in
  90 + do {
  91 + let result = try api.getFullUserAgent()
  92 + reply(wrapResult(result))
  93 + } catch {
  94 + reply(wrapError(error))
  95 + }
  96 + }
  97 + } else {
  98 + getFullUserAgentChannel.setMessageHandler(nil)
  99 + }
  100 + }
  101 +}
  1 +import Foundation
  2 +import UIKit
  3 +import WebKit
  4 +
  5 +/**
  6 + * PlatformApi iOS implementation.
  7 + *
  8 + * 组装完整 User-Agent格式与 Android 端保持一致
  9 + * `{systemWebViewUA} doublefeel/{versionCode}({versionName})(Apple##Apple##{model}; iOS{osVersion}; {height}x{width})(huawei)`
  10 + */
  11 +final class PlatformHostApiImpl: PlatformHostApi {
  12 +
  13 + // WKWebView 必须在主线程创建,且需保持引用防止释放
  14 + private static let webView: WKWebView = {
  15 + let wv = WKWebView(frame: .zero)
  16 + return wv
  17 + }()
  18 +
  19 + func getFullUserAgent() throws -> String {
  20 + // 1. 系统 WebView UA(同步读取,WKWebView 已在主线程初始化)
  21 + let systemUa = PlatformHostApiImpl.webView.value(forKey: "userAgent") as? String ?? ""
  22 +
  23 + // 2. 版本信息(对应 Android versionCode / versionName)
  24 + let info = Bundle.main.infoDictionary
  25 + let versionName = info?["CFBundleShortVersionString"] as? String ?? ""
  26 + let versionCode = info?["CFBundleVersion"] as? String ?? "0"
  27 +
  28 + // 3. 设备信息
  29 + // iOS 无 manufacturer/brand 概念,统一用 "Apple"
  30 + let manufacturer = "Apple"
  31 + let brand = "Apple"
  32 + let model = UIDevice.current.model // "iPhone" / "iPad"
  33 + let osVersion = UIDevice.current.systemVersion.replacingOccurrences(of: ".", with: "")
  34 +
  35 + // 4. 屏幕物理分辨率:长边为 height,短边为 width
  36 + let bounds = UIScreen.main.bounds
  37 + let scale = UIScreen.main.scale
  38 + let pw = bounds.width * scale
  39 + let ph = bounds.height * scale
  40 + let screenWidth = Int(min(pw, ph))
  41 + let screenHeight = Int(max(pw, ph))
  42 +
  43 + // 5. 组装 customAgent(与 Android DeviceInfoUtils.userAgent 格式一致)
  44 + let customAgent = "doublefeel/\(versionCode)(\(versionName))"
  45 + + "(\(manufacturer)##\(brand)##\(model); iOS\(osVersion); \(screenHeight)x\(screenWidth))"
  46 + + "(apple)"
  47 +
  48 + let full = "\(systemUa.trimmingCharacters(in: .whitespaces)) \(customAgent)"
  49 + .trimmingCharacters(in: .whitespaces)
  50 + return full
  51 + }
  52 +}
@@ -3,6 +3,7 @@ import 'package:get/get.dart'; @@ -3,6 +3,7 @@ import 'package:get/get.dart';
3 3
4 import '../../core/config/app_environment_config.dart'; 4 import '../../core/config/app_environment_config.dart';
5 import '../../core/logging/app_logger.dart'; 5 import '../../core/logging/app_logger.dart';
  6 +import '../../core/network/user_agent_provider.dart';
6 import '../../core/services/user_state_service.dart'; 7 import '../../core/services/user_state_service.dart';
7 import '../../data/local/local_storage.dart'; 8 import '../../data/local/local_storage.dart';
8 import '../../data/local/user_account_storage.dart'; 9 import '../../data/local/user_account_storage.dart';
@@ -15,6 +16,8 @@ abstract final class AppBootstrap { @@ -15,6 +16,8 @@ abstract final class AppBootstrap {
15 WidgetsFlutterBinding.ensureInitialized(); 16 WidgetsFlutterBinding.ensureInitialized();
16 AppLogger.init(); 17 AppLogger.init();
17 18
  19 + await UserAgentProvider.init();
  20 +
18 final local = await LocalStorage.open(); 21 final local = await LocalStorage.open();
19 22
20 final environmentConfig = AppEnvironmentConfig(local); 23 final environmentConfig = AppEnvironmentConfig(local);
  1 +import 'package:doublefeel_flutter/core/network/api/user_api.dart';
  2 +import 'package:get/get.dart';
  3 +
  4 +import '../controllers/feedback_list_controller.dart';
  5 +
  6 +class FeedbackListBinding extends Bindings {
  7 + @override
  8 + void dependencies() {
  9 + Get.lazyPut<FeedbackListController>(
  10 + () => FeedbackListController(Get.find<UserApi>()),
  11 + );
  12 + }
  13 +}
  1 +import 'package:doublefeel_flutter/core/network/api/user_api.dart';
  2 +import 'package:doublefeel_flutter/core/result/app_result.dart';
  3 +import 'package:doublefeel_flutter/core/util/app_toast.dart';
  4 +import 'package:doublefeel_flutter/data/models/user/user_models.dart';
  5 +import 'package:get/get.dart';
  6 +
  7 +class FeedbackListController extends GetxController {
  8 + FeedbackListController(this._userApi);
  9 +
  10 + final UserApi _userApi;
  11 +
  12 + final records = <FeedbackRecord>[].obs;
  13 + final isLoading = false.obs;
  14 +
  15 + @override
  16 + void onInit() {
  17 + super.onInit();
  18 + loadFeedbackList();
  19 + }
  20 +
  21 + Future<void> loadFeedbackList() async {
  22 + isLoading.value = true;
  23 + final result = await _userApi.getFeedbackList();
  24 + isLoading.value = false;
  25 +
  26 + switch (result) {
  27 + case AppSuccess(:final data):
  28 + records.assignAll(data.records ?? const []);
  29 + case AppFailure(:final error):
  30 + AppToast.show(error.displayMessage);
  31 + }
  32 + }
  33 +}
  1 +import 'package:doublefeel_flutter/core/theme/app_colors.dart';
  2 +import 'package:doublefeel_flutter/core/theme/app_theme.dart';
  3 +import 'package:doublefeel_flutter/data/models/user/user_models.dart';
  4 +import 'package:flutter/material.dart';
  5 +import 'package:get/get.dart';
  6 +
  7 +import '../controllers/feedback_list_controller.dart';
  8 +
  9 +class FeedbackListView extends GetView<FeedbackListController> {
  10 + const FeedbackListView({super.key});
  11 +
  12 + @override
  13 + Widget build(BuildContext context) {
  14 + return Scaffold(
  15 + backgroundColor: context.colors.backgroundPage,
  16 + appBar: AppBar(
  17 + backgroundColor: Colors.transparent,
  18 + surfaceTintColor: Colors.transparent,
  19 + shadowColor: Colors.transparent,
  20 + elevation: 0,
  21 + toolbarHeight: 44,
  22 + centerTitle: true,
  23 + title: const Text(
  24 + '反馈记录',
  25 + style: TextStyle(
  26 + color: Colors.black,
  27 + fontSize: 16,
  28 + fontWeight: FontWeight.w500,
  29 + ),
  30 + ),
  31 + leadingWidth: 56,
  32 + leading: IconButton(
  33 + highlightColor: Colors.transparent,
  34 + splashColor: Colors.transparent,
  35 + padding: EdgeInsets.zero,
  36 + onPressed: Get.back,
  37 + icon: Image.asset(
  38 + 'assets/images/common/ic_nav_back.webp',
  39 + width: 24,
  40 + height: 24,
  41 + ),
  42 + ),
  43 + ),
  44 + body: Obx(() {
  45 + if (controller.isLoading.value && controller.records.isEmpty) {
  46 + return const Center(child: CircularProgressIndicator());
  47 + }
  48 +
  49 + if (controller.records.isEmpty) {
  50 + return Center(
  51 + child: Text(
  52 + '暂无反馈记录',
  53 + style: TextStyle(
  54 + color: context.colors.textSecondary,
  55 + fontSize: 14,
  56 + fontWeight: FontWeight.w400,
  57 + ),
  58 + ),
  59 + );
  60 + }
  61 +
  62 + return RefreshIndicator(
  63 + onRefresh: controller.loadFeedbackList,
  64 + child: ListView.separated(
  65 + physics: const AlwaysScrollableScrollPhysics(
  66 + parent: ClampingScrollPhysics(),
  67 + ),
  68 + padding: EdgeInsets.fromLTRB(
  69 + 16,
  70 + 16,
  71 + 16,
  72 + 24 + MediaQuery.paddingOf(context).bottom,
  73 + ),
  74 + itemCount: controller.records.length,
  75 + separatorBuilder: (_, __) => const SizedBox(height: 8),
  76 + itemBuilder: (context, index) {
  77 + final record = controller.records[index];
  78 + return _FeedbackRecordCard(
  79 + record: record,
  80 + onTap: () => _showFeedbackDetail(record),
  81 + );
  82 + },
  83 + ),
  84 + );
  85 + }),
  86 + );
  87 + }
  88 +
  89 + void _showFeedbackDetail(FeedbackRecord record) {
  90 + Get.bottomSheet(
  91 + _FeedbackDetailSheet(record: record),
  92 + barrierColor: Colors.black.withValues(alpha: 0.7),
  93 + enableDrag: true,
  94 + isScrollControlled: true,
  95 + persistent: false,
  96 + );
  97 + }
  98 +}
  99 +
  100 +class _FeedbackRecordCard extends StatelessWidget {
  101 + const _FeedbackRecordCard({
  102 + required this.record,
  103 + required this.onTap,
  104 + });
  105 +
  106 + final FeedbackRecord record;
  107 + final VoidCallback onTap;
  108 +
  109 + @override
  110 + Widget build(BuildContext context) {
  111 + final hasImages = record.images.isNotEmpty;
  112 +
  113 + return GestureDetector(
  114 + behavior: HitTestBehavior.opaque,
  115 + onTap: onTap,
  116 + child: Container(
  117 + height: hasImages ? 173 : 113,
  118 + padding: const EdgeInsets.fromLTRB(20, 20, 20, 0),
  119 + decoration: BoxDecoration(
  120 + color: Colors.white,
  121 + borderRadius: BorderRadius.circular(16),
  122 + ),
  123 + child: Column(
  124 + crossAxisAlignment: CrossAxisAlignment.start,
  125 + children: [
  126 + Text(
  127 + record.content ?? '',
  128 + maxLines: 2,
  129 + overflow: TextOverflow.ellipsis,
  130 + style: TextStyle(
  131 + color: context.colors.textPrimary,
  132 + fontSize: 14,
  133 + fontWeight: FontWeight.w400,
  134 + height: 20 / 14,
  135 + ),
  136 + ),
  137 + if (hasImages) ...[
  138 + const SizedBox(height: 8),
  139 + _AttachmentPreviewRow(
  140 + imageUrls: record.images,
  141 + size: 48,
  142 + radius: 8,
  143 + maxCount: 2,
  144 + ),
  145 + ],
  146 + const SizedBox(height: 8),
  147 + const Divider(
  148 + height: 1,
  149 + thickness: 1,
  150 + color: Color(0xFFF3F3F3),
  151 + ),
  152 + const SizedBox(height: 7),
  153 + Row(
  154 + children: [
  155 + Expanded(
  156 + child: Text(
  157 + _formatTimestamp(record.createTime),
  158 + maxLines: 1,
  159 + overflow: TextOverflow.ellipsis,
  160 + style: TextStyle(
  161 + color: context.colors.textSecondary,
  162 + fontSize: 12,
  163 + fontWeight: FontWeight.w400,
  164 + height: 1.4,
  165 + ),
  166 + ),
  167 + ),
  168 + Image.asset(
  169 + 'assets/images/common/ic_more_gray.png',
  170 + width: 16,
  171 + height: 16,
  172 + ),
  173 + ],
  174 + ),
  175 + ],
  176 + ),
  177 + ),
  178 + );
  179 + }
  180 +}
  181 +
  182 +class _FeedbackDetailSheet extends StatelessWidget {
  183 + const _FeedbackDetailSheet({required this.record});
  184 +
  185 + final FeedbackRecord record;
  186 +
  187 + @override
  188 + Widget build(BuildContext context) {
  189 + return SizedBox(
  190 + height: 718,
  191 + child: Container(
  192 + decoration: BoxDecoration(
  193 + color: context.colors.backgroundPage,
  194 + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
  195 + ),
  196 + child: SafeArea(
  197 + top: false,
  198 + child: Column(
  199 + children: [
  200 + const SizedBox(height: 8),
  201 + SizedBox(
  202 + height: 56,
  203 + child: Stack(
  204 + alignment: Alignment.center,
  205 + children: [
  206 + Center(
  207 + child: Column(
  208 + mainAxisAlignment: MainAxisAlignment.center,
  209 + children: [
  210 + Text(
  211 + '反馈详情',
  212 + style: TextStyle(
  213 + color: context.colors.textPrimary,
  214 + fontSize: 16,
  215 + fontWeight: FontWeight.w600,
  216 + height: 1.2,
  217 + ),
  218 + ),
  219 + const SizedBox(height: 4),
  220 + Text(
  221 + _formatTimestamp(record.createTime),
  222 + style: TextStyle(
  223 + color: context.colors.textSecondary,
  224 + fontSize: 12,
  225 + fontWeight: FontWeight.w400,
  226 + height: 1.2,
  227 + ),
  228 + ),
  229 + ],
  230 + ),
  231 + ),
  232 + Positioned(
  233 + left: 16,
  234 + child: GestureDetector(
  235 + behavior: HitTestBehavior.opaque,
  236 + onTap: Get.back,
  237 + child: SizedBox(
  238 + width: 44,
  239 + height: 44,
  240 + child: Center(
  241 + child: Image.asset(
  242 + 'assets/images/common/ic_close.png',
  243 + width: 20,
  244 + height: 20,
  245 + color: AppColors.chartPurple,
  246 + ),
  247 + ),
  248 + ),
  249 + ),
  250 + ),
  251 + ],
  252 + ),
  253 + ),
  254 + Expanded(
  255 + child: Container(
  256 + width: double.infinity,
  257 + margin: const EdgeInsets.fromLTRB(16, 0, 16, 54),
  258 + padding: const EdgeInsets.fromLTRB(20, 20, 20, 20),
  259 + decoration: BoxDecoration(
  260 + color: Colors.white,
  261 + borderRadius: BorderRadius.circular(16),
  262 + ),
  263 + child: ListView(
  264 + physics: const ClampingScrollPhysics(),
  265 + padding: EdgeInsets.zero,
  266 + children: [
  267 + Text(
  268 + record.content ?? '',
  269 + style: TextStyle(
  270 + color: context.colors.textPrimary,
  271 + fontSize: 14,
  272 + fontWeight: FontWeight.w400,
  273 + height: 20 / 14,
  274 + ),
  275 + ),
  276 + if (record.images.isNotEmpty) ...[
  277 + const SizedBox(height: 16),
  278 + _AttachmentPreviewRow(
  279 + imageUrls: record.images,
  280 + size: 64,
  281 + radius: 12,
  282 + ),
  283 + ],
  284 + ],
  285 + ),
  286 + ),
  287 + ),
  288 + ],
  289 + ),
  290 + ),
  291 + ),
  292 + );
  293 + }
  294 +}
  295 +
  296 +class _AttachmentPreviewRow extends StatelessWidget {
  297 + const _AttachmentPreviewRow({
  298 + required this.imageUrls,
  299 + required this.size,
  300 + required this.radius,
  301 + this.maxCount,
  302 + });
  303 +
  304 + final List<String> imageUrls;
  305 + final double size;
  306 + final double radius;
  307 + final int? maxCount;
  308 +
  309 + @override
  310 + Widget build(BuildContext context) {
  311 + final urls = imageUrls.where((url) => url.isNotEmpty).toList();
  312 + final visibleUrls = maxCount == null ? urls : urls.take(maxCount!).toList();
  313 +
  314 + return Wrap(
  315 + spacing: 8,
  316 + runSpacing: 8,
  317 + children: visibleUrls
  318 + .map(
  319 + (url) => _AttachmentThumbnail(
  320 + url: url,
  321 + size: size,
  322 + radius: radius,
  323 + ),
  324 + )
  325 + .toList(),
  326 + );
  327 + }
  328 +}
  329 +
  330 +class _AttachmentThumbnail extends StatelessWidget {
  331 + const _AttachmentThumbnail({
  332 + required this.url,
  333 + required this.size,
  334 + required this.radius,
  335 + });
  336 +
  337 + final String url;
  338 + final double size;
  339 + final double radius;
  340 +
  341 + @override
  342 + Widget build(BuildContext context) {
  343 + return Container(
  344 + width: size,
  345 + height: size,
  346 + decoration: BoxDecoration(
  347 + color: Colors.white,
  348 + border: Border.all(color: const Color(0xFFE6DDFF), width: 0.8),
  349 + borderRadius: BorderRadius.circular(radius),
  350 + ),
  351 + child: ClipRRect(
  352 + borderRadius: BorderRadius.circular(radius - 1),
  353 + child: Image.network(
  354 + url,
  355 + fit: BoxFit.cover,
  356 + errorBuilder: (_, __, ___) {
  357 + return _MockAttachmentThumbnail(
  358 + size: size,
  359 + radius: radius,
  360 + );
  361 + },
  362 + ),
  363 + ),
  364 + );
  365 + }
  366 +}
  367 +
  368 +class _MockAttachmentThumbnail extends StatelessWidget {
  369 + const _MockAttachmentThumbnail({
  370 + required this.size,
  371 + required this.radius,
  372 + });
  373 +
  374 + final double size;
  375 + final double radius;
  376 +
  377 + @override
  378 + Widget build(BuildContext context) {
  379 + return Container(
  380 + width: size,
  381 + height: size,
  382 + padding: EdgeInsets.all(size * 0.12),
  383 + decoration: BoxDecoration(
  384 + color: Colors.white,
  385 + borderRadius: BorderRadius.circular(radius),
  386 + ),
  387 + child: Column(
  388 + crossAxisAlignment: CrossAxisAlignment.start,
  389 + children: [
  390 + Center(
  391 + child: Container(
  392 + width: size * 0.32,
  393 + height: size * 0.13,
  394 + decoration: BoxDecoration(
  395 + color: AppColors.primary.withValues(alpha: 0.18),
  396 + borderRadius: BorderRadius.circular(size * 0.08),
  397 + ),
  398 + ),
  399 + ),
  400 + SizedBox(height: size * 0.1),
  401 + _ThumbnailLine(width: size * 0.58),
  402 + SizedBox(height: size * 0.08),
  403 + _ThumbnailLine(width: size * 0.44),
  404 + const Spacer(),
  405 + Row(
  406 + children: [
  407 + _ThumbnailBlock(color: const Color(0xFFFFEEF3), size: size),
  408 + SizedBox(width: size * 0.05),
  409 + _ThumbnailBlock(color: const Color(0xFFEAF2FF), size: size),
  410 + SizedBox(width: size * 0.05),
  411 + _ThumbnailBlock(color: const Color(0xFFEFF9F5), size: size),
  412 + ],
  413 + ),
  414 + ],
  415 + ),
  416 + );
  417 + }
  418 +}
  419 +
  420 +class _ThumbnailLine extends StatelessWidget {
  421 + const _ThumbnailLine({required this.width});
  422 +
  423 + final double width;
  424 +
  425 + @override
  426 + Widget build(BuildContext context) {
  427 + return Container(
  428 + width: width,
  429 + height: 2,
  430 + decoration: BoxDecoration(
  431 + color: const Color(0xFFE8E1FF),
  432 + borderRadius: BorderRadius.circular(1),
  433 + ),
  434 + );
  435 + }
  436 +}
  437 +
  438 +class _ThumbnailBlock extends StatelessWidget {
  439 + const _ThumbnailBlock({
  440 + required this.color,
  441 + required this.size,
  442 + });
  443 +
  444 + final Color color;
  445 + final double size;
  446 +
  447 + @override
  448 + Widget build(BuildContext context) {
  449 + return Expanded(
  450 + child: Container(
  451 + height: size * 0.18,
  452 + decoration: BoxDecoration(
  453 + color: color,
  454 + borderRadius: BorderRadius.circular(size * 0.05),
  455 + ),
  456 + ),
  457 + );
  458 + }
  459 +}
  460 +
  461 +String _formatTimestamp(int? timestamp) {
  462 + if (timestamp == null || timestamp <= 0) return '';
  463 +
  464 + final date = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
  465 + final month = date.month.toString().padLeft(2, '0');
  466 + final day = date.day.toString().padLeft(2, '0');
  467 + final hour = date.hour.toString().padLeft(2, '0');
  468 + final minute = date.minute.toString().padLeft(2, '0');
  469 + final second = date.second.toString().padLeft(2, '0');
  470 + return '${date.year}/$month/$day $hour:$minute:$second';
  471 +}
  1 +import 'package:doublefeel_flutter/core/network/api/user_api.dart';
  2 +import 'package:get/get.dart';
  3 +
  4 +import '../controllers/submit_feedback_controller.dart';
  5 +
  6 +class SubmitFeedbackBinding extends Bindings {
  7 + @override
  8 + void dependencies() {
  9 + Get.lazyPut<SubmitFeedbackController>(
  10 + () => SubmitFeedbackController(Get.find<UserApi>()),
  11 + );
  12 + }
  13 +}
  1 +import 'package:doublefeel_flutter/core/network/api/user_api.dart';
  2 +import 'package:doublefeel_flutter/core/result/app_result.dart';
  3 +import 'package:doublefeel_flutter/core/util/app_toast.dart';
  4 +import 'package:flutter/material.dart';
  5 +import 'package:get/get.dart';
  6 +import 'package:image_picker/image_picker.dart';
  7 +
  8 +class SubmitFeedbackController extends GetxController {
  9 + SubmitFeedbackController(this._userApi);
  10 +
  11 + final UserApi _userApi;
  12 + static const maxFeedbackLength = 1500;
  13 + static const maxImageCount = 5;
  14 +
  15 + final feedbackTextController = TextEditingController();
  16 + final contactTextController = TextEditingController();
  17 + final selectedImages = <XFile>[].obs;
  18 +
  19 + final ImagePicker _picker = ImagePicker();
  20 +
  21 + Future<void> pickImages() async {
  22 + final remainingCount = maxImageCount - selectedImages.length;
  23 + if (remainingCount <= 0) {
  24 + AppToast.show('最多上传5张凭证');
  25 + return;
  26 + }
  27 +
  28 + final List<XFile> images;
  29 + try {
  30 + images = await _picker.pickMultiImage(
  31 + limit: remainingCount,
  32 + imageQuality: 85,
  33 + );
  34 + } catch (_) {
  35 + AppToast.show('无法选择图片,请稍后重试');
  36 + return;
  37 + }
  38 +
  39 + if (images.isEmpty) return;
  40 +
  41 + selectedImages.addAll(images.take(remainingCount));
  42 + }
  43 +
  44 + void removeImage(XFile image) {
  45 + selectedImages.remove(image);
  46 + }
  47 +
  48 + Future<void> submit() async {
  49 + if (feedbackTextController.text.trim().isEmpty) {
  50 + AppToast.show('请输入问题和反馈');
  51 + return;
  52 + }
  53 + final result = await _userApi.submitFeedback(
  54 + content: feedbackTextController.text.trim(),
  55 + email: contactTextController.text.trim(),
  56 + images: selectedImages.map((e) => e.path).toList(),
  57 + );
  58 + switch (result) {
  59 + case AppSuccess():
  60 + AppToast.show('提交成功');
  61 + Get.back();
  62 + case AppFailure(:final error):
  63 + AppToast.show(error.displayMessage);
  64 + }
  65 + }
  66 +
  67 + @override
  68 + void onClose() {
  69 + feedbackTextController.dispose();
  70 + contactTextController.dispose();
  71 + super.onClose();
  72 + }
  73 +}
  1 +import 'dart:io';
  2 +
  3 +import 'package:doublefeel_flutter/core/theme/app_colors.dart';
  4 +import 'package:doublefeel_flutter/core/theme/app_theme.dart';
  5 +import 'package:flutter/material.dart';
  6 +import 'package:get/get.dart';
  7 +import 'package:image_picker/image_picker.dart';
  8 +
  9 +import '../controllers/submit_feedback_controller.dart';
  10 +
  11 +class SubmitFeedbackView extends GetView<SubmitFeedbackController> {
  12 + const SubmitFeedbackView({super.key});
  13 +
  14 + static const _background = Color(0xFFF5F2FF);
  15 +
  16 + @override
  17 + Widget build(BuildContext context) {
  18 + return Scaffold(
  19 + resizeToAvoidBottomInset: true,
  20 + backgroundColor: _background,
  21 + appBar: AppBar(
  22 + backgroundColor: Colors.transparent,
  23 + surfaceTintColor: Colors.transparent,
  24 + shadowColor: Colors.transparent,
  25 + elevation: 0,
  26 + toolbarHeight: 44,
  27 + centerTitle: true,
  28 + title: const Text(
  29 + '问题反馈',
  30 + style: TextStyle(
  31 + color: Colors.black,
  32 + fontSize: 16,
  33 + fontWeight: FontWeight.w500,
  34 + ),
  35 + ),
  36 + leadingWidth: 56,
  37 + leading: IconButton(
  38 + highlightColor: Colors.transparent,
  39 + splashColor: Colors.transparent,
  40 + padding: EdgeInsets.zero,
  41 + onPressed: Get.back,
  42 + icon: Image.asset(
  43 + 'assets/images/common/ic_nav_back.webp',
  44 + width: 24,
  45 + height: 24,
  46 + ),
  47 + ),
  48 + ),
  49 + body: Stack(
  50 + children: [
  51 + ListView(
  52 + physics: const ClampingScrollPhysics(),
  53 + padding: EdgeInsets.fromLTRB(
  54 + 15,
  55 + 16,
  56 + 16,
  57 + 132 + MediaQuery.paddingOf(context).bottom,
  58 + ),
  59 + children: [
  60 + const _FeedbackLabel(),
  61 + SizedBox(height: 9),
  62 + const _FeedbackInput(),
  63 + SizedBox(height: 20),
  64 + const _SectionLabel('联系方式'),
  65 + SizedBox(height: 8),
  66 + const _ContactInput(),
  67 + SizedBox(height: 20),
  68 + const _UploadHeader(),
  69 + SizedBox(height: 8),
  70 + const _ImagePickerGrid(),
  71 + ],
  72 + ),
  73 + Positioned(
  74 + left: 0,
  75 + right: 0,
  76 + bottom: 36 + MediaQuery.paddingOf(context).bottom,
  77 + child: Center(
  78 + child: GestureDetector(
  79 + behavior: HitTestBehavior.opaque,
  80 + onTap: controller.submit,
  81 + child: Container(
  82 + width: 280,
  83 + height: 48,
  84 + alignment: Alignment.center,
  85 + decoration: BoxDecoration(
  86 + color: AppColors.primary,
  87 + borderRadius: BorderRadius.circular(24),
  88 + ),
  89 + child: const Text(
  90 + '提交',
  91 + style: TextStyle(
  92 + color: Colors.white,
  93 + fontSize: 16,
  94 + fontWeight: FontWeight.w600,
  95 + ),
  96 + ),
  97 + ),
  98 + ),
  99 + ),
  100 + ),
  101 + ],
  102 + ),
  103 + );
  104 + }
  105 +}
  106 +
  107 +class _FeedbackLabel extends StatelessWidget {
  108 + const _FeedbackLabel();
  109 +
  110 + @override
  111 + Widget build(BuildContext context) {
  112 + return RichText(
  113 + text: TextSpan(
  114 + style: TextStyle(
  115 + color: context.colors.textPrimary,
  116 + fontSize: 12,
  117 + fontWeight: FontWeight.w500,
  118 + height: 1.4,
  119 + ),
  120 + children: const [
  121 + TextSpan(
  122 + text: '* ',
  123 + style: TextStyle(color: AppColors.warning),
  124 + ),
  125 + TextSpan(text: '问题和反馈'),
  126 + ],
  127 + ),
  128 + );
  129 + }
  130 +}
  131 +
  132 +class _SectionLabel extends StatelessWidget {
  133 + const _SectionLabel(this.text);
  134 +
  135 + final String text;
  136 +
  137 + @override
  138 + Widget build(BuildContext context) {
  139 + return Text(
  140 + text,
  141 + style: TextStyle(
  142 + color: context.colors.textPrimary,
  143 + fontSize: 12,
  144 + fontWeight: FontWeight.w500,
  145 + height: 1.4,
  146 + ),
  147 + );
  148 + }
  149 +}
  150 +
  151 +class _FeedbackInput extends GetView<SubmitFeedbackController> {
  152 + const _FeedbackInput();
  153 +
  154 + @override
  155 + Widget build(BuildContext context) {
  156 + return Container(
  157 + height: 200,
  158 + decoration: BoxDecoration(
  159 + color: Colors.white,
  160 + borderRadius: BorderRadius.circular(16),
  161 + ),
  162 + child: Stack(
  163 + children: [
  164 + Positioned.fill(
  165 + child: TextField(
  166 + controller: controller.feedbackTextController,
  167 + maxLength: SubmitFeedbackController.maxFeedbackLength,
  168 + maxLines: null,
  169 + minLines: null,
  170 + expands: true,
  171 + textAlignVertical: TextAlignVertical.top,
  172 + keyboardType: TextInputType.multiline,
  173 + style: TextStyle(
  174 + color: context.colors.textPrimary,
  175 + fontSize: 14,
  176 + fontWeight: FontWeight.w400,
  177 + height: 20 / 14,
  178 + ),
  179 + decoration: InputDecoration(
  180 + border: InputBorder.none,
  181 + enabledBorder: InputBorder.none,
  182 + focusedBorder: InputBorder.none,
  183 + counterText: '',
  184 + hintText: '请详细描述你遇到的问题或建议',
  185 + hintStyle: TextStyle(
  186 + color: context.colors.textTertiary,
  187 + fontSize: 14,
  188 + fontWeight: FontWeight.w400,
  189 + height: 20 / 14,
  190 + ),
  191 + contentPadding: EdgeInsets.fromLTRB(
  192 + 20,
  193 + 20,
  194 + 20,
  195 + 38,
  196 + ),
  197 + ),
  198 + ),
  199 + ),
  200 + Positioned(
  201 + right: 20,
  202 + bottom: 20,
  203 + child: ValueListenableBuilder<TextEditingValue>(
  204 + valueListenable: controller.feedbackTextController,
  205 + builder: (context, value, _) {
  206 + return Text(
  207 + '${value.text.length}/${SubmitFeedbackController.maxFeedbackLength}',
  208 + style: TextStyle(
  209 + color: context.colors.textTertiary,
  210 + fontSize: 12,
  211 + fontWeight: FontWeight.w400,
  212 + height: 20 / 12,
  213 + ),
  214 + );
  215 + },
  216 + ),
  217 + ),
  218 + ],
  219 + ),
  220 + );
  221 + }
  222 +}
  223 +
  224 +class _ContactInput extends GetView<SubmitFeedbackController> {
  225 + const _ContactInput();
  226 +
  227 + @override
  228 + Widget build(BuildContext context) {
  229 + return Container(
  230 + height: 52,
  231 + decoration: BoxDecoration(
  232 + color: Colors.white,
  233 + borderRadius: BorderRadius.circular(16),
  234 + ),
  235 + alignment: Alignment.center,
  236 + child: TextField(
  237 + controller: controller.contactTextController,
  238 + keyboardType: TextInputType.emailAddress,
  239 + textInputAction: TextInputAction.done,
  240 + style: TextStyle(
  241 + color: context.colors.textPrimary,
  242 + fontSize: 14,
  243 + fontWeight: FontWeight.w400,
  244 + height: 1.4,
  245 + ),
  246 + decoration: InputDecoration(
  247 + border: InputBorder.none,
  248 + enabledBorder: InputBorder.none,
  249 + focusedBorder: InputBorder.none,
  250 + hintText: '如果需要我们回复,请填写联系邮箱',
  251 + hintStyle: TextStyle(
  252 + color: context.colors.textTertiary,
  253 + fontSize: 14,
  254 + fontWeight: FontWeight.w400,
  255 + height: 1.4,
  256 + ),
  257 + contentPadding: EdgeInsets.symmetric(horizontal: 20),
  258 + ),
  259 + ),
  260 + );
  261 + }
  262 +}
  263 +
  264 +class _UploadHeader extends GetView<SubmitFeedbackController> {
  265 + const _UploadHeader();
  266 +
  267 + @override
  268 + Widget build(BuildContext context) {
  269 + return Row(
  270 + children: [
  271 + const _SectionLabel('上传凭证'),
  272 + SizedBox(width: 5),
  273 + Obx(
  274 + () => Text(
  275 + '${controller.selectedImages.length}/${SubmitFeedbackController.maxImageCount}',
  276 + style: TextStyle(
  277 + color: context.colors.textTertiary,
  278 + fontSize: 12,
  279 + fontWeight: FontWeight.w400,
  280 + height: 20 / 12,
  281 + ),
  282 + ),
  283 + ),
  284 + ],
  285 + );
  286 + }
  287 +}
  288 +
  289 +class _ImagePickerGrid extends GetView<SubmitFeedbackController> {
  290 + const _ImagePickerGrid();
  291 +
  292 + @override
  293 + Widget build(BuildContext context) {
  294 + return Obx(() {
  295 + final images = controller.selectedImages;
  296 + final canAdd = images.length < SubmitFeedbackController.maxImageCount;
  297 +
  298 + return Wrap(
  299 + spacing: 8,
  300 + runSpacing: 8,
  301 + children: [
  302 + ...images.map(
  303 + (image) => _SelectedImageTile(
  304 + image: image,
  305 + onRemove: () => controller.removeImage(image),
  306 + ),
  307 + ),
  308 + if (canAdd)
  309 + _AddImageTile(
  310 + onTap: controller.pickImages,
  311 + ),
  312 + ],
  313 + );
  314 + });
  315 + }
  316 +}
  317 +
  318 +class _SelectedImageTile extends StatelessWidget {
  319 + const _SelectedImageTile({
  320 + required this.image,
  321 + required this.onRemove,
  322 + });
  323 +
  324 + final XFile image;
  325 + final VoidCallback onRemove;
  326 +
  327 + @override
  328 + Widget build(BuildContext context) {
  329 + return SizedBox(
  330 + width: 72,
  331 + height: 72,
  332 + child: Stack(
  333 + clipBehavior: Clip.none,
  334 + children: [
  335 + Positioned.fill(
  336 + child: DecoratedBox(
  337 + decoration: BoxDecoration(
  338 + border: Border.all(color: const Color(0xFFE6DDFF)),
  339 + borderRadius: BorderRadius.circular(16),
  340 + ),
  341 + child: ClipRRect(
  342 + borderRadius: BorderRadius.circular(15),
  343 + child: Image.file(
  344 + File(image.path),
  345 + fit: BoxFit.cover,
  346 + ),
  347 + ),
  348 + ),
  349 + ),
  350 + Positioned(
  351 + top: -4,
  352 + right: -4,
  353 + child: GestureDetector(
  354 + behavior: HitTestBehavior.opaque,
  355 + onTap: onRemove,
  356 + child: Container(
  357 + width: 18,
  358 + height: 18,
  359 + decoration: const BoxDecoration(
  360 + color: AppColors.warning,
  361 + shape: BoxShape.circle,
  362 + ),
  363 + child: Icon(
  364 + Icons.close,
  365 + color: Colors.white,
  366 + size: 13,
  367 + ),
  368 + ),
  369 + ),
  370 + ),
  371 + ],
  372 + ),
  373 + );
  374 + }
  375 +}
  376 +
  377 +class _AddImageTile extends StatelessWidget {
  378 + const _AddImageTile({required this.onTap});
  379 +
  380 + final VoidCallback onTap;
  381 +
  382 + @override
  383 + Widget build(BuildContext context) {
  384 + return GestureDetector(
  385 + behavior: HitTestBehavior.opaque,
  386 + onTap: onTap,
  387 + child: CustomPaint(
  388 + painter: _DashedRoundedRectPainter(
  389 + color: context.colors.textTertiary,
  390 + radius: 16,
  391 + ),
  392 + child: SizedBox(
  393 + width: 72,
  394 + height: 72,
  395 + child: Center(
  396 + child: Icon(
  397 + Icons.add,
  398 + size: 28,
  399 + color: context.colors.textTertiary,
  400 + ),
  401 + ),
  402 + ),
  403 + ),
  404 + );
  405 + }
  406 +}
  407 +
  408 +class _DashedRoundedRectPainter extends CustomPainter {
  409 + const _DashedRoundedRectPainter({
  410 + required this.color,
  411 + required this.radius,
  412 + });
  413 +
  414 + final Color color;
  415 + final double radius;
  416 +
  417 + @override
  418 + void paint(Canvas canvas, Size size) {
  419 + final paint = Paint()
  420 + ..color = color
  421 + ..strokeWidth = 1
  422 + ..style = PaintingStyle.stroke;
  423 + final rrect = RRect.fromRectAndRadius(
  424 + Offset.zero & size,
  425 + Radius.circular(radius),
  426 + );
  427 + final path = Path()..addRRect(rrect);
  428 + final metrics = path.computeMetrics();
  429 +
  430 + for (final metric in metrics) {
  431 + var distance = 0.0;
  432 + const dashWidth = 4.0;
  433 + const dashGap = 3.0;
  434 + while (distance < metric.length) {
  435 + final nextDistance = distance + dashWidth;
  436 + canvas.drawPath(
  437 + metric.extractPath(distance, nextDistance),
  438 + paint,
  439 + );
  440 + distance = nextDistance + dashGap;
  441 + }
  442 + }
  443 + }
  444 +
  445 + @override
  446 + bool shouldRepaint(covariant _DashedRoundedRectPainter oldDelegate) {
  447 + return oldDelegate.color != color || oldDelegate.radius != radius;
  448 + }
  449 +}
  1 +import 'package:get/get.dart';
  2 +
  3 +import '../controllers/help_controller.dart';
  4 +
  5 +class HelpBinding extends Bindings {
  6 + @override
  7 + void dependencies() {
  8 + Get.lazyPut<HelpController>(
  9 + () => HelpController(),
  10 + );
  11 + }
  12 +}
  1 +import 'package:get/get.dart';
  2 +
  3 +class HelpController extends GetxController {}
  1 +import 'package:doublefeel_flutter/app/routes/app_pages.dart';
  2 +import 'package:doublefeel_flutter/core/theme/app_colors.dart';
  3 +import 'package:doublefeel_flutter/core/theme/app_theme.dart';
  4 +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
  5 +
  6 +import 'package:flutter/material.dart';
  7 +import 'package:get/get.dart';
  8 +
  9 +import '../controllers/help_controller.dart';
  10 +
  11 +class HelpView extends GetView<HelpController> {
  12 + const HelpView({super.key});
  13 +
  14 + @override
  15 + Widget build(BuildContext context) {
  16 + final faqItems = _buildFaqItems(context);
  17 +
  18 + return Scaffold(
  19 + appBar: AppBar(
  20 + title: const Text(
  21 + '帮助',
  22 + ),
  23 + leadingWidth: 56,
  24 + leading: IconButton(
  25 + highlightColor: Colors.transparent,
  26 + splashColor: Colors.transparent,
  27 + padding: EdgeInsets.zero,
  28 + onPressed: Get.back,
  29 + icon: Image.asset(
  30 + 'assets/images/common/ic_nav_back.webp',
  31 + width: 24,
  32 + height: 24,
  33 + ),
  34 + ),
  35 + ),
  36 + body: Stack(
  37 + children: [
  38 + ListView(
  39 + physics: const ClampingScrollPhysics(),
  40 + padding: EdgeInsets.fromLTRB(
  41 + 16,
  42 + 16,
  43 + 16,
  44 + 132 + MediaQuery.paddingOf(context).bottom,
  45 + ),
  46 + children: [
  47 + _FaqCard(items: faqItems),
  48 + SizedBox(height: 12),
  49 + _ActionRow(
  50 + title: '反馈记录',
  51 + onTap: () => Get.toNamed(Routes.FEEDBACK_LIST),
  52 + ),
  53 + ],
  54 + ),
  55 + Positioned(
  56 + left: 0,
  57 + right: 0,
  58 + bottom: 36 + MediaQuery.paddingOf(context).bottom,
  59 + child: Center(
  60 + child: _PrimaryHelpButton(
  61 + label: '问题反馈',
  62 + onTap: () => Get.toNamed(Routes.SUBMIT_FEEDBACK),
  63 + ),
  64 + ),
  65 + ),
  66 + ],
  67 + ),
  68 + );
  69 + }
  70 +
  71 + List<({String title, List<String> body})> _buildFaqItems(
  72 + BuildContext context,
  73 + ) {
  74 + final l10n = context.l10n;
  75 +
  76 + return [
  77 + (
  78 + title: l10n.todayFaqLinkNoData,
  79 + body: const [
  80 + '1、 确定苹果手表系统在10.0以上,手机系统在14以上,系统版本可在【关于本机】内查看。',
  81 + '2、确认是否开启所有权限:手机【健康】-【共享】-【app】-【DoubleFeel】-【打开所有权限】',
  82 + '3、确认设备是否处于省电模式、低电量状态或手表佩戴未贴紧,以上情况会影响手表数据采集。',
  83 + '如以上均检查无问题,可以在【意见反馈】-【联系我们】中提交相关问题,我们看到后会第一时间回复。',
  84 + ],
  85 + ),
  86 + (
  87 + title: l10n.todayFaqLinkHrvRealtimeUpdate,
  88 + body: [
  89 + l10n.todayRealtimeStressScenarioHrvDefault,
  90 + l10n.todayRealtimeStressScenarioRegionLimit,
  91 + l10n.todayRealtimeStressScenarioIntro,
  92 + l10n.todayRealtimeStressScenarioUpdateEvery6Min,
  93 + l10n.todayRealtimeStressScenarioTimely,
  94 + l10n.todayRealtimeStressScenarioConsistentTrend,
  95 + l10n.todayRealtimeStressScenarioSummary,
  96 + ],
  97 + ),
  98 + (
  99 + title: l10n.todayFaqLinkWatchNoStatusAndInteractionNotification,
  100 + body: [
  101 + l10n.todayFaqWatchNoNotificationDescription1,
  102 + l10n.todayFaqWatchNoNotificationDescription2,
  103 + l10n.todayFaqWatchNoNotificationCheckPhoneNotification,
  104 + l10n.todayFaqWatchNoNotificationCheckPhoneBackgroundRefresh,
  105 + l10n.todayFaqWatchNoNotificationCheckWatchBackgroundRefresh,
  106 + l10n.todayFaqWatchNoNotificationCheckModes,
  107 + l10n.todayFaqWatchNoNotificationReinstall,
  108 + ],
  109 + ),
  110 + (
  111 + title: l10n.todayFaqLinkWatchFaceDataDelay,
  112 + body: [
  113 + l10n.todayFaqWatchFaceDelayDescription1,
  114 + l10n.todayFaqWatchFaceDelayIfOverOneHour,
  115 + l10n.todayFaqWatchFaceDelayOpenWatchApp,
  116 + l10n.todayFaqWatchFaceDelayIfStill,
  117 + l10n.todayFaqWatchFaceDelayRestartApp,
  118 + l10n.todayFaqWatchFaceDelayCheckIntro,
  119 + l10n.todayFaqWatchFaceDelayCheckData,
  120 + l10n.todayFaqWatchFaceDelayCheckPhoneHealth,
  121 + l10n.todayFaqWatchFaceDelayCheckWatchHealth,
  122 + l10n.todayFaqWatchFaceDelayCheckBackgroundRefresh,
  123 + l10n.todayFaqWatchFaceDelayRestartWatch,
  124 + ],
  125 + ),
  126 + (
  127 + title: l10n.todayFaqLinkWatchFaceBlackScreen,
  128 + body: [
  129 + l10n.todayFaqWatchFaceBlackScreenDescription,
  130 + ],
  131 + ),
  132 + ];
  133 + }
  134 +}
  135 +
  136 +class _FaqCard extends StatelessWidget {
  137 + const _FaqCard({required this.items});
  138 +
  139 + final List<({String title, List<String> body})> items;
  140 +
  141 + @override
  142 + Widget build(BuildContext context) {
  143 + return Container(
  144 + height: 257,
  145 + padding: EdgeInsets.fromLTRB(20, 20, 20, 9),
  146 + decoration: BoxDecoration(
  147 + color: Colors.white,
  148 + borderRadius: BorderRadius.circular(16),
  149 + ),
  150 + child: Column(
  151 + crossAxisAlignment: CrossAxisAlignment.start,
  152 + children: [
  153 + Text(
  154 + '常见问题',
  155 + style: TextStyle(
  156 + color: context.colors.textPrimary,
  157 + fontSize: 14,
  158 + fontWeight: FontWeight.w600,
  159 + height: 1.4,
  160 + ),
  161 + ),
  162 + SizedBox(height: 8),
  163 + ...items.map(
  164 + (item) => _QuestionRow(
  165 + title: item.title,
  166 + onTap: () => _showQuestionBottomSheet(item),
  167 + ),
  168 + ),
  169 + ],
  170 + ),
  171 + );
  172 + }
  173 +
  174 + void _showQuestionBottomSheet(({String title, List<String> body}) item) {
  175 + Get.bottomSheet(
  176 + _QuestionBottomSheet(item: item),
  177 + barrierColor: Colors.black.withValues(alpha: 0.7),
  178 + enableDrag: true,
  179 + isScrollControlled: true,
  180 + persistent: false,
  181 + );
  182 + }
  183 +}
  184 +
  185 +class _QuestionRow extends StatelessWidget {
  186 + const _QuestionRow({
  187 + required this.title,
  188 + required this.onTap,
  189 + });
  190 +
  191 + final String title;
  192 + final VoidCallback onTap;
  193 +
  194 + @override
  195 + Widget build(BuildContext context) {
  196 + return GestureDetector(
  197 + behavior: HitTestBehavior.opaque,
  198 + onTap: onTap,
  199 + child: SizedBox(
  200 + height: 40,
  201 + child: Row(
  202 + children: [
  203 + Expanded(
  204 + child: Text(
  205 + title,
  206 + maxLines: 1,
  207 + overflow: TextOverflow.ellipsis,
  208 + style: TextStyle(
  209 + color: context.colors.textPrimary,
  210 + fontSize: 12,
  211 + fontWeight: FontWeight.w400,
  212 + height: 1.4,
  213 + ),
  214 + ),
  215 + ),
  216 + SizedBox(width: 12),
  217 + Image.asset(
  218 + 'assets/images/common/ic_more_gray.png',
  219 + width: 16,
  220 + height: 16,
  221 + ),
  222 + ],
  223 + ),
  224 + ),
  225 + );
  226 + }
  227 +}
  228 +
  229 +class _ActionRow extends StatelessWidget {
  230 + const _ActionRow({
  231 + required this.title,
  232 + required this.onTap,
  233 + });
  234 +
  235 + final String title;
  236 + final VoidCallback onTap;
  237 +
  238 + @override
  239 + Widget build(BuildContext context) {
  240 + return GestureDetector(
  241 + behavior: HitTestBehavior.opaque,
  242 + onTap: onTap,
  243 + child: Container(
  244 + height: 56,
  245 + padding: EdgeInsets.symmetric(horizontal: 20),
  246 + decoration: BoxDecoration(
  247 + color: Colors.white,
  248 + borderRadius: BorderRadius.circular(16),
  249 + ),
  250 + child: Row(
  251 + children: [
  252 + Expanded(
  253 + child: Text(
  254 + title,
  255 + maxLines: 1,
  256 + overflow: TextOverflow.ellipsis,
  257 + style: TextStyle(
  258 + color: context.colors.textPrimary,
  259 + fontSize: 14,
  260 + fontWeight: FontWeight.w600,
  261 + height: 1.4,
  262 + ),
  263 + ),
  264 + ),
  265 + Image.asset(
  266 + 'assets/images/common/ic_more_gray.png',
  267 + width: 16,
  268 + height: 16,
  269 + ),
  270 + ],
  271 + ),
  272 + ),
  273 + );
  274 + }
  275 +}
  276 +
  277 +class _PrimaryHelpButton extends StatelessWidget {
  278 + const _PrimaryHelpButton({
  279 + required this.label,
  280 + required this.onTap,
  281 + });
  282 +
  283 + final String label;
  284 + final VoidCallback onTap;
  285 +
  286 + @override
  287 + Widget build(BuildContext context) {
  288 + return GestureDetector(
  289 + behavior: HitTestBehavior.opaque,
  290 + onTap: onTap,
  291 + child: Container(
  292 + width: 280,
  293 + height: 48,
  294 + alignment: Alignment.center,
  295 + decoration: BoxDecoration(
  296 + color: AppColors.primary,
  297 + borderRadius: BorderRadius.circular(24),
  298 + ),
  299 + child: Text(
  300 + label,
  301 + style: const TextStyle(
  302 + color: Colors.white,
  303 + fontSize: 16,
  304 + fontWeight: FontWeight.w600,
  305 + ),
  306 + ),
  307 + ),
  308 + );
  309 + }
  310 +}
  311 +
  312 +class _QuestionBottomSheet extends StatelessWidget {
  313 + const _QuestionBottomSheet({required this.item});
  314 +
  315 + final ({String title, List<String> body}) item;
  316 +
  317 + @override
  318 + Widget build(BuildContext context) {
  319 + return SizedBox(
  320 + height: 331,
  321 + child: Container(
  322 + decoration: BoxDecoration(
  323 + color: context.colors.backgroundPage,
  324 + borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
  325 + ),
  326 + child: SafeArea(
  327 + top: false,
  328 + child: Column(
  329 + children: [
  330 + SizedBox(height: 8),
  331 + SizedBox(
  332 + height: 56,
  333 + child: Stack(
  334 + alignment: Alignment.center,
  335 + children: [
  336 + Padding(
  337 + padding: EdgeInsets.symmetric(horizontal: 72),
  338 + child: Text(
  339 + item.title,
  340 + maxLines: 1,
  341 + overflow: TextOverflow.ellipsis,
  342 + textAlign: TextAlign.center,
  343 + style: TextStyle(
  344 + color: context.colors.textPrimary,
  345 + fontSize: 16,
  346 + fontWeight: FontWeight.w600,
  347 + height: 1.2,
  348 + ),
  349 + ),
  350 + ),
  351 + Positioned(
  352 + left: 16,
  353 + child: GestureDetector(
  354 + behavior: HitTestBehavior.opaque,
  355 + onTap: Get.back,
  356 + child: SizedBox(
  357 + width: 44,
  358 + height: 44,
  359 + child: Center(
  360 + child: Image.asset(
  361 + 'assets/images/common/ic_close.png',
  362 + width: 20,
  363 + height: 20,
  364 + color: context.colors.chartPurple,
  365 + ),
  366 + ),
  367 + ),
  368 + ),
  369 + ),
  370 + ],
  371 + ),
  372 + ),
  373 + Container(
  374 + height: 215,
  375 + margin: EdgeInsets.symmetric(horizontal: 16),
  376 + padding: EdgeInsets.fromLTRB(20, 20, 20, 18),
  377 + decoration: BoxDecoration(
  378 + color: Colors.white,
  379 + borderRadius: BorderRadius.circular(16),
  380 + ),
  381 + child: ListView.separated(
  382 + physics: const ClampingScrollPhysics(),
  383 + padding: EdgeInsets.zero,
  384 + itemCount: item.body.length,
  385 + separatorBuilder: (_, __) => SizedBox(height: 12),
  386 + itemBuilder: (context, index) {
  387 + return Text(
  388 + item.body[index],
  389 + style: TextStyle(
  390 + color: context.colors.textSecondary,
  391 + fontSize: 12,
  392 + fontWeight: FontWeight.w400,
  393 + height: 17 / 12,
  394 + ),
  395 + );
  396 + },
  397 + ),
  398 + ),
  399 + ],
  400 + ),
  401 + ),
  402 + ),
  403 + );
  404 + }
  405 +}
  1 +import 'package:doublefeel_flutter/app/modules/home/controllers/my_controller.dart';
1 import 'package:doublefeel_flutter/core/network/api/health_api.dart'; 2 import 'package:doublefeel_flutter/core/network/api/health_api.dart';
2 import 'package:doublefeel_flutter/core/network/api/user_api.dart'; 3 import 'package:doublefeel_flutter/core/network/api/user_api.dart';
3 import 'package:doublefeel_flutter/core/services/health_kit_upload_service.dart'; 4 import 'package:doublefeel_flutter/core/services/health_kit_upload_service.dart';
@@ -25,6 +26,8 @@ class HomeBinding extends Bindings { @@ -25,6 +26,8 @@ class HomeBinding extends Bindings {
25 ), 26 ),
26 fenix: true, 27 fenix: true,
27 ); 28 );
  29 + Get.lazyPut<MyController>(() => MyController(Get.find<UserApi>()),
  30 + fenix: true);
28 Get.lazyPut<TrendController>(() => TrendController(), fenix: true); 31 Get.lazyPut<TrendController>(() => TrendController(), fenix: true);
29 Get.lazyPut<HrvController>(() => HrvController(), fenix: true); 32 Get.lazyPut<HrvController>(() => HrvController(), fenix: true);
30 Get.lazyPut<FriendsController>(() => FriendsController(), fenix: true); 33 Get.lazyPut<FriendsController>(() => FriendsController(), fenix: true);
  1 +import 'package:doublefeel_flutter/core/network/api/user_api.dart';
  2 +import 'package:get/get_state_manager/src/simple/get_controllers.dart';
  3 +
  4 +class MyController extends GetxController {
  5 + MyController(this._userApi);
  6 +
  7 + final UserApi _userApi;
  8 +}
1 import 'dart:async'; 1 import 'dart:async';
2 2
3 -import 'package:doublefeel_flutter/app/modules/home/views/no_health_data_page.dart'; 3 +import 'package:doublefeel_flutter/app/modules/home/widgets/today/no_health_data_page.dart';
4 import 'package:doublefeel_flutter/core/logging/app_logger.dart'; 4 import 'package:doublefeel_flutter/core/logging/app_logger.dart';
5 import 'package:doublefeel_flutter/core/network/api/health_api.dart'; 5 import 'package:doublefeel_flutter/core/network/api/health_api.dart';
6 import 'package:doublefeel_flutter/core/network/api/user_api.dart'; 6 import 'package:doublefeel_flutter/core/network/api/user_api.dart';
1 -import 'package:doublefeel_flutter/app/modules/watch_theme/models/watch_theme_models.dart'; 1 +import 'package:cached_network_image/cached_network_image.dart';
  2 +import 'package:doublefeel_flutter/app/models/input_dialog_meta_data.dart';
  3 +import 'package:doublefeel_flutter/app/modules/home/controllers/my_controller.dart';
  4 +import 'package:doublefeel_flutter/app/modules/home/widgets/my/account_setting_view.dart';
2 import 'package:doublefeel_flutter/app/routes/app_pages.dart'; 5 import 'package:doublefeel_flutter/app/routes/app_pages.dart';
3 -import 'package:doublefeel_flutter/core/theme/app_colors.dart';  
4 -import 'package:doublefeel_flutter/core/util/size_extensions.dart'; 6 +import 'package:doublefeel_flutter/app/utils/dialog_utils.dart';
  7 +import 'package:doublefeel_flutter/core/theme/app_theme.dart';
5 import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart'; 8 import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
  9 +import 'package:doublefeel_flutter/data/models/local/user_preferences.dart';
6 import 'package:doublefeel_flutter/data/models/user/user_models.dart'; 10 import 'package:doublefeel_flutter/data/models/user/user_models.dart';
7 -import 'package:doublefeel_flutter/r.dart'; 11 +import 'package:flutter/foundation.dart';
8 import 'package:flutter/material.dart'; 12 import 'package:flutter/material.dart';
9 import 'package:get/get.dart'; 13 import 'package:get/get.dart';
10 14
11 -class MyTab extends StatelessWidget { 15 +class MyTab extends GetView<MyController> {
12 const MyTab({super.key}); 16 const MyTab({super.key});
13 17
14 - static const _background = Color(0xFFF5F2FF);  
15 - static const _proPurple = Color(0xFF916DF5);  
16 - static const _proGold = Color(0xFFFFDF51); 18 + static const _bgColor = Color(0xFFF5F2FF);
17 19
18 @override 20 @override
19 Widget build(BuildContext context) { 21 Widget build(BuildContext context) {
20 final userPrefs = Get.find<UserPreferencesStorage>(); 22 final userPrefs = Get.find<UserPreferencesStorage>();
  23 +
21 return Container( 24 return Container(
22 - color: _background, 25 + color: _bgColor,
23 child: SafeArea( 26 child: SafeArea(
24 bottom: false, 27 bottom: false,
25 child: Obx(() { 28 child: Obx(() {
26 - final user = userPrefs.preferences.value.meUserInfo; 29 + final preferences = userPrefs.preferences.value;
  30 + final me = preferences.meUserInfo;
  31 + final vipInfo = preferences.vipInfo;
  32 +
27 return ListView( 33 return ListView(
28 physics: const ClampingScrollPhysics(), 34 physics: const ClampingScrollPhysics(),
29 - padding: EdgeInsets.fromLTRB(16.dp, 59.dp, 16.dp, 112.dp), 35 + padding: EdgeInsets.fromLTRB(
  36 + 16,
  37 + 59,
  38 + 16,
  39 + 150 + MediaQuery.paddingOf(context).bottom,
  40 + ),
30 children: [ 41 children: [
31 - _ProfileHeader(  
32 - user: user,  
33 - onTap: () => Get.toNamed(Routes.ACCOUNT_SETTINGS), 42 + Padding(
  43 + padding: const EdgeInsets.only(left: 8),
  44 + child: _ProfileHeader(user: me),
34 ), 45 ),
35 - SizedBox(height: 28.dp),  
36 - const _PremiumCard(),  
37 - SizedBox(height: 12.dp),  
38 - _WatchThemeCard(  
39 - onTap: () => Get.toNamed(  
40 - Routes.WATCH_THEME,  
41 - arguments: {'isPremium': false, 'hasCustomThemes': false},  
42 - ),  
43 - ),  
44 - SizedBox(height: 12.dp),  
45 - _MenuTile( 46 + const SizedBox(height: 28),
  47 + _ProCard(vipInfo: vipInfo),
  48 + const SizedBox(height: 12),
  49 + const _WatchThemeCard(),
  50 + const SizedBox(height: 12),
  51 + _SettingsRow(
46 title: '账号信息', 52 title: '账号信息',
47 - onTap: () => Get.toNamed(Routes.ACCOUNT_SETTINGS), 53 + onTap: () => Get.to(() => const AccountSettingView()),
  54 + ),
  55 + const SizedBox(height: 12),
  56 + _SettingsRow(
  57 + title: '帮助',
  58 + onTap: () => Get.toNamed(Routes.HELP),
48 ), 59 ),
49 - SizedBox(height: 12.dp),  
50 - _MenuTile(title: '帮助', onTap: () {}), 60 + if (kDebugMode) ...[
  61 + const SizedBox(height: 12),
  62 + _SettingsRow(
  63 + title: 'Route List',
  64 + onTap: () {
  65 + Get.toNamed(Routes.ROUTE_LIST);
  66 + },
  67 + ),
  68 + ],
51 ], 69 ],
52 ); 70 );
53 }), 71 }),
@@ -57,135 +75,135 @@ class MyTab extends StatelessWidget { @@ -57,135 +75,135 @@ class MyTab extends StatelessWidget {
57 } 75 }
58 76
59 class _ProfileHeader extends StatelessWidget { 77 class _ProfileHeader extends StatelessWidget {
60 - const _ProfileHeader({  
61 - required this.user,  
62 - required this.onTap,  
63 - }); 78 + const _ProfileHeader({required this.user});
64 79
65 final UserInfoResponse? user; 80 final UserInfoResponse? user;
66 - final VoidCallback onTap;  
67 81
68 @override 82 @override
69 Widget build(BuildContext context) { 83 Widget build(BuildContext context) {
70 - return GestureDetector(  
71 - behavior: HitTestBehavior.opaque,  
72 - onTap: onTap,  
73 - child: SizedBox(  
74 - height: 80.dp,  
75 - child: Row(  
76 - crossAxisAlignment: CrossAxisAlignment.center,  
77 - children: [  
78 - _Avatar(avatarUrl: user?.avatar),  
79 - SizedBox(width: 12.dp),  
80 - Expanded( 84 + return SizedBox(
  85 + height: 80,
  86 + child: Row(
  87 + crossAxisAlignment: CrossAxisAlignment.start,
  88 + children: [
  89 + _Avatar(avatarUrl: user?.avatar ?? ''),
  90 + const SizedBox(width: 12),
  91 + Expanded(
  92 + child: Padding(
  93 + padding: const EdgeInsets.only(top: 11),
81 child: Column( 94 child: Column(
82 - mainAxisAlignment: MainAxisAlignment.center,  
83 crossAxisAlignment: CrossAxisAlignment.start, 95 crossAxisAlignment: CrossAxisAlignment.start,
84 children: [ 96 children: [
85 Row( 97 Row(
86 children: [ 98 children: [
87 Flexible( 99 Flexible(
88 child: Text( 100 child: Text(
89 - _displayName(user), 101 + user?.nickname ?? '',
90 maxLines: 1, 102 maxLines: 1,
91 overflow: TextOverflow.ellipsis, 103 overflow: TextOverflow.ellipsis,
92 style: TextStyle( 104 style: TextStyle(
93 - color: AppColors.textPrimary,  
94 - fontSize: 20.dp, 105 + color: context.colors.textPrimary,
  106 + fontSize: 20,
95 fontWeight: FontWeight.w500, 107 fontWeight: FontWeight.w500,
96 - height: 1.25, 108 + height: 1.4,
97 ), 109 ),
98 ), 110 ),
99 ), 111 ),
100 - SizedBox(width: 8.dp),  
101 - Icon(  
102 - Icons.edit_outlined,  
103 - color: const Color(0xFFA084EF),  
104 - size: 16.dp, 112 + const SizedBox(width: 8),
  113 + GestureDetector(
  114 + behavior: HitTestBehavior.opaque,
  115 + onTap: () async {
  116 + await DialogUtils.showInputDialog(
  117 + InputDialogMetaData(
  118 + title: '修改昵称',
  119 + initialValue: user?.nickname ?? '',
  120 + hintText: '请输入昵称',
  121 + confirmText: '保存',
  122 + maxLength: 6,
  123 + ),
  124 + );
  125 + },
  126 + child: Image.asset(
  127 + 'assets/images/common/ic_edit_outlined.webp',
  128 + width: 16,
  129 + height: 16,
  130 + ),
105 ), 131 ),
106 ], 132 ],
107 ), 133 ),
108 - SizedBox(height: 6.dp), 134 + const SizedBox(height: 1),
109 Text( 135 Text(
110 - 'ID:${user?.id ?? 738293}', 136 + 'ID:${user?.id ?? 0}',
111 maxLines: 1, 137 maxLines: 1,
112 overflow: TextOverflow.ellipsis, 138 overflow: TextOverflow.ellipsis,
113 style: TextStyle( 139 style: TextStyle(
114 - color: AppColors.textSecondary,  
115 - fontSize: 14.dp, 140 + color: context.colors.textSecondary,
  141 + fontSize: 14,
116 fontWeight: FontWeight.w400, 142 fontWeight: FontWeight.w400,
117 - height: 1.25, 143 + height: 1.4,
118 ), 144 ),
119 ), 145 ),
120 ], 146 ],
121 ), 147 ),
122 ), 148 ),
123 - ],  
124 - ), 149 + ),
  150 + ],
125 ), 151 ),
126 ); 152 );
127 } 153 }
128 -  
129 - String _displayName(UserInfoResponse? user) {  
130 - final name = user?.nickname?.trim();  
131 - if (name != null && name.isNotEmpty) {  
132 - return name;  
133 - }  
134 - return '不爱吃热干面';  
135 - }  
136 } 154 }
137 155
138 class _Avatar extends StatelessWidget { 156 class _Avatar extends StatelessWidget {
139 - const _Avatar({this.avatarUrl}); 157 + const _Avatar({required this.avatarUrl});
140 158
141 - final String? avatarUrl; 159 + final String avatarUrl;
142 160
143 @override 161 @override
144 Widget build(BuildContext context) { 162 Widget build(BuildContext context) {
145 - final url = avatarUrl?.trim();  
146 - final imageProvider = url == null || url.isEmpty  
147 - ? AssetImage(R.assetsImagesMyAvatarDefault) as ImageProvider  
148 - : NetworkImage(url);  
149 -  
150 - return SizedBox(  
151 - width: 80.dp,  
152 - height: 80.dp,  
153 - child: Stack(  
154 - children: [  
155 - ClipOval(  
156 - child: Image(  
157 - image: imageProvider,  
158 - width: 80.dp,  
159 - height: 80.dp, 163 + return Stack(
  164 + clipBehavior: Clip.none,
  165 + children: [
  166 + ClipOval(
  167 + child: SizedBox(
  168 + width: 80,
  169 + height: 80,
  170 + child: CachedNetworkImage(
  171 + imageUrl: avatarUrl,
160 fit: BoxFit.cover, 172 fit: BoxFit.cover,
161 ), 173 ),
162 ), 174 ),
163 - Positioned(  
164 - right: 0,  
165 - bottom: 0, 175 + ),
  176 + Positioned(
  177 + right: 0,
  178 + bottom: 0,
  179 + child: GestureDetector(
  180 + behavior: HitTestBehavior.opaque,
  181 + onTap: () {},
166 child: Container( 182 child: Container(
167 - width: 24.dp,  
168 - height: 24.dp,  
169 - decoration: BoxDecoration(  
170 - color: const Color(0xFFE8E0FF), 183 + width: 24,
  184 + height: 24,
  185 + decoration: const BoxDecoration(
  186 + color: Color(0xFFEBE5FF),
171 shape: BoxShape.circle, 187 shape: BoxShape.circle,
172 - border: Border.all(color: MyTab._background, width: 2.dp),  
173 ), 188 ),
174 - child: Icon(  
175 - Icons.photo_camera_outlined,  
176 - color: const Color(0xFFA084EF),  
177 - size: 14.dp, 189 + padding: const EdgeInsets.all(4),
  190 + child: Image.asset(
  191 + 'assets/images/common/ic_camera_outlined.webp',
  192 + width: 16,
  193 + height: 16,
178 ), 194 ),
179 ), 195 ),
180 ), 196 ),
181 - ],  
182 - ), 197 + ),
  198 + ],
183 ); 199 );
184 } 200 }
185 } 201 }
186 202
187 -class _PremiumCard extends StatelessWidget {  
188 - const _PremiumCard(); 203 +class _ProCard extends StatelessWidget {
  204 + const _ProCard({required this.vipInfo});
  205 +
  206 + final UserPreferencesVipInfo? vipInfo;
189 207
190 @override 208 @override
191 Widget build(BuildContext context) { 209 Widget build(BuildContext context) {
@@ -193,240 +211,116 @@ class _PremiumCard extends StatelessWidget { @@ -193,240 +211,116 @@ class _PremiumCard extends StatelessWidget {
193 behavior: HitTestBehavior.opaque, 211 behavior: HitTestBehavior.opaque,
194 onTap: () => Get.toNamed(Routes.PURCHASE), 212 onTap: () => Get.toNamed(Routes.PURCHASE),
195 child: Container( 213 child: Container(
196 - height: 128.dp, 214 + height: 80,
  215 + padding: const EdgeInsets.fromLTRB(20, 14, 20, 14),
197 decoration: BoxDecoration( 216 decoration: BoxDecoration(
198 - color: MyTab._proPurple,  
199 - borderRadius: BorderRadius.circular(16.dp), 217 + gradient: const LinearGradient(
  218 + begin: Alignment.centerLeft,
  219 + end: Alignment.centerRight,
  220 + colors: [
  221 + Color(0xFF916DF5),
  222 + Color(0xFF8D64F4),
  223 + ],
  224 + ),
  225 + borderRadius: BorderRadius.circular(16),
200 ), 226 ),
201 - clipBehavior: Clip.antiAlias,  
202 - child: Stack( 227 + child: Row(
  228 + crossAxisAlignment: CrossAxisAlignment.start,
203 children: [ 229 children: [
204 - Positioned(  
205 - right: 0,  
206 - bottom: 0,  
207 - child: SizedBox(  
208 - width: 151.dp,  
209 - height: 114.dp,  
210 - child: Stack(  
211 - alignment: Alignment.bottomRight,  
212 - children: [  
213 - Positioned(  
214 - left: 0,  
215 - bottom: -14.dp,  
216 - child: Container(  
217 - width: 86.dp,  
218 - height: 110.dp,  
219 - decoration: const BoxDecoration(  
220 - color: Color(0xFFD8CFF7),  
221 - borderRadius: BorderRadius.only(  
222 - topLeft: Radius.circular(12),  
223 - topRight: Radius.circular(12),  
224 - ),  
225 - ),  
226 - ),  
227 - ),  
228 - Container(  
229 - width: 80.dp,  
230 - height: 120.dp,  
231 - decoration: BoxDecoration(  
232 - color: Colors.white,  
233 - borderRadius: BorderRadius.only(  
234 - topLeft: Radius.circular(8.dp),  
235 - topRight: Radius.circular(8.dp),  
236 - ),  
237 - ),  
238 - ),  
239 - ],  
240 - ),  
241 - ),  
242 - ),  
243 - Padding(  
244 - padding: EdgeInsets.fromLTRB(20.dp, 20.dp, 20.dp, 20.dp), 230 + Expanded(
245 child: Column( 231 child: Column(
246 crossAxisAlignment: CrossAxisAlignment.start, 232 crossAxisAlignment: CrossAxisAlignment.start,
247 children: [ 233 children: [
248 - ShaderMask(  
249 - blendMode: BlendMode.srcIn,  
250 - shaderCallback: (bounds) => const LinearGradient(  
251 - begin: Alignment.topCenter,  
252 - end: Alignment.bottomCenter,  
253 - colors: [  
254 - Color(0xFFFFE8AE),  
255 - Color(0xFFFFFAED),  
256 - Colors.white,  
257 - ],  
258 - ).createShader(bounds),  
259 - child: Text(  
260 - '解锁专业版',  
261 - style: TextStyle(  
262 - fontSize: 16.dp,  
263 - fontWeight: FontWeight.w600,  
264 - height: 1.25, 234 + Row(
  235 + children: [
  236 + const Flexible(
  237 + child: Text(
  238 + 'DoubleFeel Pro',
  239 + maxLines: 1,
  240 + overflow: TextOverflow.ellipsis,
  241 + style: TextStyle(
  242 + color: Colors.white,
  243 + fontSize: 18,
  244 + fontWeight: FontWeight.w600,
  245 + height: 1.4,
  246 + ),
  247 + ),
265 ), 248 ),
266 - ), 249 + const SizedBox(width: 6),
  250 + Image.asset(
  251 + 'assets/images/common/ic_pro_badge.webp',
  252 + width: 43,
  253 + height: 16,
  254 + ),
  255 + ],
267 ), 256 ),
268 - SizedBox(height: 5.dp), 257 + const SizedBox(height: 2),
269 Text( 258 Text(
270 - '开启压力预警与健康陪伴之旅',  
271 - style: TextStyle(  
272 - color: const Color(0xFFC6B3FF),  
273 - fontSize: 12.dp, 259 + _vipSubtitle(vipInfo),
  260 + maxLines: 1,
  261 + overflow: TextOverflow.ellipsis,
  262 + style: const TextStyle(
  263 + color: Color(0xFFC6B3FF),
  264 + fontSize: 12,
274 fontWeight: FontWeight.w500, 265 fontWeight: FontWeight.w500,
275 - height: 1.25,  
276 - ),  
277 - ),  
278 - const Spacer(),  
279 - Container(  
280 - height: 32.dp,  
281 - width: 129.dp,  
282 - alignment: Alignment.center,  
283 - decoration: BoxDecoration(  
284 - color: MyTab._proGold,  
285 - borderRadius: BorderRadius.circular(24.dp),  
286 - ),  
287 - child: Row(  
288 - mainAxisAlignment: MainAxisAlignment.center,  
289 - children: [  
290 - Icon(  
291 - Icons.workspace_premium,  
292 - color: AppColors.textPrimary,  
293 - size: 16.dp,  
294 - ),  
295 - SizedBox(width: 4.dp),  
296 - Text(  
297 - '立即解锁',  
298 - style: TextStyle(  
299 - color: AppColors.textPrimary,  
300 - fontSize: 14.dp,  
301 - fontWeight: FontWeight.w500,  
302 - height: 1.25,  
303 - ),  
304 - ),  
305 - ], 266 + height: 1.4,
306 ), 267 ),
307 ), 268 ),
308 ], 269 ],
309 ), 270 ),
310 ), 271 ),
  272 + const SizedBox(width: 12),
  273 + Padding(
  274 + padding: const EdgeInsets.only(top: 5),
  275 + child: Image.asset(
  276 + 'assets/images/common/ic_more_gray.png',
  277 + width: 16,
  278 + height: 16,
  279 + color: Colors.white,
  280 + ),
  281 + ),
311 ], 282 ],
312 ), 283 ),
313 ), 284 ),
314 ); 285 );
315 } 286 }
316 -}  
317 -  
318 -class _WatchThemeCard extends StatelessWidget {  
319 - const _WatchThemeCard({required this.onTap});  
320 -  
321 - final VoidCallback onTap;  
322 287
323 - final double _itemSize = 64;  
324 - final double _overlap = 8; 288 + String _vipSubtitle(UserPreferencesVipInfo? vipInfo) {
  289 + final endDate = vipInfo?.vipEndDate ?? 0;
  290 + if (vipInfo?.isVip != true || endDate <= 0) {
  291 + return '立即解锁';
  292 + }
325 293
326 - @override  
327 - Widget build(BuildContext context) {  
328 - return GestureDetector(  
329 - behavior: HitTestBehavior.opaque,  
330 - onTap: onTap,  
331 - child: Container(  
332 - padding: EdgeInsets.fromLTRB(20.dp, 20.dp, 20.dp, 18.dp),  
333 - decoration: BoxDecoration(  
334 - color: Colors.white,  
335 - borderRadius: BorderRadius.circular(16.dp),  
336 - ),  
337 - child: Column(  
338 - crossAxisAlignment: CrossAxisAlignment.start,  
339 - children: [  
340 - Row(  
341 - children: [  
342 - Text(  
343 - 'Watch主题',  
344 - style: TextStyle(  
345 - color: AppColors.textPrimary,  
346 - fontSize: 16.dp,  
347 - fontWeight: FontWeight.w600,  
348 - height: 1.25,  
349 - ),  
350 - ),  
351 - const Spacer(),  
352 - Icon(  
353 - Icons.chevron_right,  
354 - color: AppColors.textTertiary,  
355 - size: 20.dp,  
356 - ),  
357 - ],  
358 - ),  
359 - SizedBox(height: 17.dp),  
360 - SizedBox(  
361 - height: _itemSize,  
362 - child: ListView.builder(  
363 - scrollDirection: Axis.horizontal,  
364 - clipBehavior: Clip.none,  
365 - itemCount: officialThemes.length,  
366 - itemBuilder: (context, index) {  
367 - return Transform.translate(  
368 - offset: Offset(index == 0 ? 0 : -_overlap * index, 0),  
369 - child: _ThemeBubble(  
370 - size: _itemSize,  
371 - assetPath: officialThemes[index]  
372 - .infoList  
373 - .firstOrNull  
374 - ?.assetPath ??  
375 - '',  
376 - ));  
377 - }, 294 + if (vipInfo?.isForeverVip ?? false) {
  295 + return '终身会员';
  296 + }
378 297
379 - ),  
380 - ),  
381 - SizedBox(height: 10.dp),  
382 - Text(  
383 - '支持自定义创作主题哦~',  
384 - style: TextStyle(  
385 - color: AppColors.primary,  
386 - fontSize: 12.dp,  
387 - fontWeight: FontWeight.w500,  
388 - height: 1.25,  
389 - ),  
390 - ),  
391 - ],  
392 - ),  
393 - ),  
394 - ); 298 + final milliseconds = endDate > 1000000000000 ? endDate : endDate * 1000;
  299 + final date = DateTime.fromMillisecondsSinceEpoch(milliseconds);
  300 + final month = date.month.toString().padLeft(2, '0');
  301 + final day = date.day.toString().padLeft(2, '0');
  302 + return '有效期至 ${date.year}-$month-$day';
395 } 303 }
396 } 304 }
397 305
398 -class _ThemeBubble extends StatelessWidget {  
399 - const _ThemeBubble({required this.size, required this.assetPath});  
400 -  
401 - final double size;  
402 - final String assetPath; 306 +class _WatchThemeCard extends StatelessWidget {
  307 + const _WatchThemeCard();
403 308
404 @override 309 @override
405 Widget build(BuildContext context) { 310 Widget build(BuildContext context) {
406 return Container( 311 return Container(
407 - width: size,  
408 - height: size, 312 + height: 145,
409 decoration: BoxDecoration( 313 decoration: BoxDecoration(
410 - color: const Color(0xFFEDE8FF),  
411 - shape: BoxShape.circle,  
412 - border: Border.all(color: Colors.white, width: 4),  
413 - ),  
414 - alignment: Alignment.center,  
415 - child: Image.asset(  
416 - assetPath,  
417 - width: 36,  
418 - height: 36,  
419 - fit: BoxFit.fill, 314 + color: Colors.white,
  315 + borderRadius: BorderRadius.circular(16),
420 ), 316 ),
  317 + child: Image.asset('assets/images/my/ic_my_watch_theme.png'),
421 ); 318 );
422 } 319 }
423 } 320 }
424 321
425 -class _MenuTile extends StatelessWidget {  
426 - const _MenuTile({  
427 - required this.title,  
428 - required this.onTap,  
429 - }); 322 +class _SettingsRow extends StatelessWidget {
  323 + const _SettingsRow({required this.title, required this.onTap});
430 324
431 final String title; 325 final String title;
432 final VoidCallback onTap; 326 final VoidCallback onTap;
@@ -437,28 +331,31 @@ class _MenuTile extends StatelessWidget { @@ -437,28 +331,31 @@ class _MenuTile extends StatelessWidget {
437 behavior: HitTestBehavior.opaque, 331 behavior: HitTestBehavior.opaque,
438 onTap: onTap, 332 onTap: onTap,
439 child: Container( 333 child: Container(
440 - height: 56.dp,  
441 - padding: EdgeInsets.symmetric(horizontal: 20.dp), 334 + height: 56,
  335 + padding: const EdgeInsets.symmetric(horizontal: 20),
442 decoration: BoxDecoration( 336 decoration: BoxDecoration(
443 color: Colors.white, 337 color: Colors.white,
444 - borderRadius: BorderRadius.circular(16.dp), 338 + borderRadius: BorderRadius.circular(16),
445 ), 339 ),
446 child: Row( 340 child: Row(
447 children: [ 341 children: [
448 - Text(  
449 - title,  
450 - style: TextStyle(  
451 - color: AppColors.textPrimary,  
452 - fontSize: 14.dp,  
453 - fontWeight: FontWeight.w400,  
454 - height: 1.25, 342 + Expanded(
  343 + child: Text(
  344 + title,
  345 + maxLines: 1,
  346 + overflow: TextOverflow.ellipsis,
  347 + style: TextStyle(
  348 + color: context.colors.textPrimary,
  349 + fontSize: 14,
  350 + fontWeight: FontWeight.w400,
  351 + height: 1.4,
  352 + ),
455 ), 353 ),
456 ), 354 ),
457 - const Spacer(),  
458 - Icon(  
459 - Icons.chevron_right,  
460 - color: AppColors.textTertiary,  
461 - size: 20.dp, 355 + Image.asset(
  356 + 'assets/images/common/ic_more_gray.png',
  357 + width: 16,
  358 + height: 16,
462 ), 359 ),
463 ], 360 ],
464 ), 361 ),
  1 +import 'package:doublefeel_flutter/app/modules/home/controllers/my_controller.dart';
  2 +import 'package:doublefeel_flutter/app/routes/app_pages.dart';
  3 +
  4 +import 'package:doublefeel_flutter/core/network/api/user_api.dart';
  5 +import 'package:doublefeel_flutter/core/result/app_result.dart';
  6 +import 'package:doublefeel_flutter/core/services/user_state_service.dart';
  7 +import 'package:doublefeel_flutter/core/theme/app_colors.dart';
  8 +import 'package:doublefeel_flutter/core/theme/app_theme.dart';
  9 +
  10 +import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
  11 +import 'package:doublefeel_flutter/r.dart';
  12 +import 'package:flutter/material.dart';
  13 +
  14 +import 'package:get/get.dart';
  15 +
  16 +class AccountSettingView extends GetView<MyController> {
  17 + const AccountSettingView({super.key});
  18 +
  19 + @override
  20 + Widget build(BuildContext context) {
  21 + final userPrefs = Get.find<UserPreferencesStorage>();
  22 +
  23 + return Scaffold(
  24 + appBar: AppBar(
  25 + title: const Text(
  26 + '账号设置',
  27 + ),
  28 + leading: IconButton(
  29 + highlightColor: Colors.transparent,
  30 + padding: EdgeInsets.zero,
  31 + onPressed: Get.back,
  32 + icon: Image(
  33 + width: 24,
  34 + height: 24,
  35 + alignment: Alignment.centerLeft,
  36 + image: AssetImage(R.assetsImagesNavBackIcon),
  37 + ),
  38 + ),
  39 + ),
  40 + body: Obx(
  41 + () => Column(
  42 + children: [
  43 + SizedBox(height: 16),
  44 + _buildAccountCard(
  45 + phone: userPrefs.preferences.value.meUserInfo?.telephone ?? '',
  46 + ),
  47 + SizedBox(height: 20),
  48 + GestureDetector(
  49 + behavior: HitTestBehavior.opaque,
  50 + onTap: () {
  51 + Get.bottomSheet(
  52 + _DeleteAccountBottomSheet(onDeleteAccount: _deleteAccount),
  53 + barrierColor: Colors.black.withValues(alpha: 0.7),
  54 + enableDrag: true,
  55 + isScrollControlled: true,
  56 + persistent: false,
  57 + );
  58 + },
  59 + child: Padding(
  60 + padding: EdgeInsets.symmetric(
  61 + horizontal: 24,
  62 + vertical: 8,
  63 + ),
  64 + child: const Text(
  65 + '注销账号',
  66 + style: TextStyle(
  67 + color: AppColors.warning,
  68 + fontSize: 12,
  69 + fontWeight: FontWeight.w400,
  70 + height: 1.4,
  71 + ),
  72 + ),
  73 + ),
  74 + ),
  75 + ],
  76 + ),
  77 + ),
  78 + );
  79 + }
  80 +
  81 + Widget _buildAccountCard({required String phone}) {
  82 + return Container(
  83 + height: 110,
  84 + margin: EdgeInsets.symmetric(horizontal: 15),
  85 + decoration: BoxDecoration(
  86 + color: Colors.white,
  87 + borderRadius: BorderRadius.circular(16),
  88 + ),
  89 + child: Column(
  90 + children: [
  91 + SizedBox(
  92 + height: 54,
  93 + child: Padding(
  94 + padding: EdgeInsets.symmetric(horizontal: 20),
  95 + child: Row(
  96 + children: [
  97 + const Text(
  98 + '手机号',
  99 + style: TextStyle(
  100 + color: AppColors.textPrimary,
  101 + fontSize: 14,
  102 + fontWeight: FontWeight.w400,
  103 + height: 1.4,
  104 + ),
  105 + ),
  106 + SizedBox(width: 16),
  107 + Expanded(
  108 + child: Text(
  109 + phone,
  110 + maxLines: 1,
  111 + overflow: TextOverflow.ellipsis,
  112 + textAlign: TextAlign.right,
  113 + style: const TextStyle(
  114 + color: AppColors.textSecondary,
  115 + fontSize: 14,
  116 + fontWeight: FontWeight.w400,
  117 + height: 1.4,
  118 + ),
  119 + ),
  120 + ),
  121 + ],
  122 + ),
  123 + ),
  124 + ),
  125 + Padding(
  126 + padding: EdgeInsets.symmetric(horizontal: 21),
  127 + child: const Divider(
  128 + height: 1,
  129 + thickness: 0.5,
  130 + color: Color(0xFFF3F3F3),
  131 + ),
  132 + ),
  133 + Expanded(
  134 + child: GestureDetector(
  135 + behavior: HitTestBehavior.opaque,
  136 + onTap: _logout,
  137 + child: const Center(
  138 + child: Text(
  139 + '退出登录',
  140 + style: TextStyle(
  141 + color: AppColors.primary,
  142 + fontSize: 14,
  143 + fontWeight: FontWeight.w500,
  144 + height: 1.4,
  145 + ),
  146 + ),
  147 + ),
  148 + ),
  149 + ),
  150 + ],
  151 + ),
  152 + );
  153 + }
  154 +
  155 + Future<void> _logout() async {
  156 + await Get.find<UserStateService>().onLogout();
  157 + Get.offAllNamed(AppRoutes.login);
  158 + }
  159 +
  160 + Future<void> _deleteAccount() async {
  161 + final deleteResult = await Get.find<UserApi>().deleteAccount();
  162 + if (deleteResult is! AppSuccess<void>) return;
  163 +
  164 + await Get.find<UserStateService>().onLogout(callServerLogout: false);
  165 + Get.offAllNamed(AppRoutes.login);
  166 + }
  167 +}
  168 +
  169 +class _DeleteAccountBottomSheet extends StatelessWidget {
  170 + const _DeleteAccountBottomSheet({required this.onDeleteAccount});
  171 +
  172 + final VoidCallback onDeleteAccount;
  173 +
  174 + @override
  175 + Widget build(BuildContext context) {
  176 + return Container(
  177 + decoration: BoxDecoration(
  178 + color: context.colors.backgroundPage,
  179 + borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
  180 + ),
  181 + child: SafeArea(
  182 + top: false,
  183 + child: Column(
  184 + mainAxisSize: MainAxisSize.min,
  185 + children: [
  186 + SizedBox(height: 8),
  187 + SizedBox(
  188 + height: 56,
  189 + child: Stack(
  190 + alignment: Alignment.center,
  191 + children: [
  192 + Center(
  193 + child: Padding(
  194 + padding: EdgeInsets.symmetric(horizontal: 72),
  195 + child: Text(
  196 + '确认注销账号吗?',
  197 + maxLines: 1,
  198 + overflow: TextOverflow.ellipsis,
  199 + textAlign: TextAlign.center,
  200 + style: TextStyle(
  201 + color: context.colors.textPrimary,
  202 + fontSize: 16,
  203 + fontWeight: FontWeight.w600,
  204 + height: 1.2,
  205 + ),
  206 + ),
  207 + ),
  208 + ),
  209 + Positioned(
  210 + left: 16,
  211 + child: GestureDetector(
  212 + behavior: HitTestBehavior.opaque,
  213 + onTap: Get.back,
  214 + child: SizedBox(
  215 + width: 44,
  216 + height: 44,
  217 + child: Center(
  218 + child: Image.asset(
  219 + 'assets/images/common/ic_close.png',
  220 + width: 20,
  221 + height: 20,
  222 + color: context.colors.chartPurple,
  223 + ),
  224 + ),
  225 + ),
  226 + ),
  227 + ),
  228 + ],
  229 + ),
  230 + ),
  231 + Container(
  232 + margin: EdgeInsets.symmetric(horizontal: 16),
  233 + padding: EdgeInsets.fromLTRB(20, 20, 20, 18),
  234 + decoration: BoxDecoration(
  235 + color: Colors.white,
  236 + borderRadius: BorderRadius.circular(16),
  237 + ),
  238 + child: Column(
  239 + crossAxisAlignment: CrossAxisAlignment.start,
  240 + children: [
  241 + Text(
  242 + '注销账号后将无法找回!请谨慎操作',
  243 + textAlign: TextAlign.center,
  244 + style: TextStyle(
  245 + color: context.colors.warning,
  246 + fontSize: 14,
  247 + fontWeight: FontWeight.w600,
  248 + ),
  249 + ),
  250 + SizedBox(
  251 + height: 12,
  252 + ),
  253 + Text(
  254 + '提示:注销账号将会删除该账号内包括但不限于\n个人资料、情绪记录、统计数据等全部信息。',
  255 + style: TextStyle(
  256 + color: context.colors.textSecondary,
  257 + fontSize: 12,
  258 + fontWeight: FontWeight.w400,
  259 + ),
  260 + ),
  261 + SizedBox(
  262 + height: 12,
  263 + ),
  264 + Text(
  265 + '注1:你的健康数据会保存在苹果健康,我们不会删除苹果健康中的数据。',
  266 + style: TextStyle(
  267 + color: context.colors.textSecondary,
  268 + fontSize: 12,
  269 + fontWeight: FontWeight.w400,
  270 + ),
  271 + ),
  272 + SizedBox(
  273 + height: 12,
  274 + ),
  275 + Text(
  276 + '注2:删除账号不会影响你在App Store的订阅状态,如果需要取消订阅,请在APPs Store - 头像 - 订阅中手动取消订阅。',
  277 + style: TextStyle(
  278 + color: context.colors.textSecondary,
  279 + fontSize: 12,
  280 + fontWeight: FontWeight.w400,
  281 + ),
  282 + )
  283 + ],
  284 + ),
  285 + ),
  286 + SizedBox(
  287 + height: 45,
  288 + ),
  289 + GestureDetector(
  290 + onTap: onDeleteAccount,
  291 + child: Text(
  292 + '确认注销',
  293 + style: TextStyle(
  294 + color: context.colors.warning,
  295 + fontSize: 16,
  296 + fontWeight: FontWeight.w500,
  297 + ),
  298 + ),
  299 + ),
  300 + GestureDetector(
  301 + onTap: () {
  302 + Get.back();
  303 + },
  304 + child: Container(
  305 + width: 280,
  306 + height: 48,
  307 + margin: EdgeInsets.only(top: 17, bottom: 20),
  308 + padding:
  309 + const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
  310 + decoration: ShapeDecoration(
  311 + color: context.colors.primary,
  312 + shape: RoundedRectangleBorder(
  313 + borderRadius: BorderRadius.circular(24),
  314 + ),
  315 + ),
  316 + child: Row(
  317 + mainAxisSize: MainAxisSize.min,
  318 + mainAxisAlignment: MainAxisAlignment.center,
  319 + crossAxisAlignment: CrossAxisAlignment.center,
  320 + spacing: 24,
  321 + children: [
  322 + Text(
  323 + '我再想想',
  324 + style: TextStyle(
  325 + color: Colors.white,
  326 + fontSize: 16,
  327 + fontWeight: FontWeight.w600,
  328 + ),
  329 + ),
  330 + ],
  331 + ),
  332 + ),
  333 + )
  334 + ],
  335 + ),
  336 + ),
  337 + );
  338 + }
  339 +}
@@ -13,8 +13,6 @@ class NoHealthDataPage extends StatelessWidget { @@ -13,8 +13,6 @@ class NoHealthDataPage extends StatelessWidget {
13 static const _backgroundColor = Color(0xFFF5F2FF); 13 static const _backgroundColor = Color(0xFFF5F2FF);
14 14
15 static const _placeholderColor = Color(0xFFD9D9D9); 15 static const _placeholderColor = Color(0xFFD9D9D9);
16 - static const _imagePlaceholderColor = Color(0xFFF3F3F3);  
17 - static const _screenShotBackground = Color(0xFFF2F2F6);  
18 16
19 final VoidCallback? onRefresh; 17 final VoidCallback? onRefresh;
20 final VoidCallback? onHelp; 18 final VoidCallback? onHelp;
@@ -23,7 +23,7 @@ class TodayHrvAdBanner extends GetView<TodayController> { @@ -23,7 +23,7 @@ class TodayHrvAdBanner extends GetView<TodayController> {
23 shape: BoxShape.circle, 23 shape: BoxShape.circle,
24 ), 24 ),
25 child: Image.asset( 25 child: Image.asset(
26 - 'assets/images/common/ic_chevron_right.png', 26 + 'assets/images/common/ic_arrow_forward.png',
27 width: 20, 27 width: 20,
28 height: 20, 28 height: 20,
29 color: context.colors.primary, 29 color: context.colors.primary,
1 import 'dart:async'; 1 import 'dart:async';
2 2
  3 +import 'package:doublefeel_flutter/core/theme/app_theme.dart';
3 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 4 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
4 import 'package:flutter/material.dart'; 5 import 'package:flutter/material.dart';
5 import 'package:get/get.dart'; 6 import 'package:get/get.dart';
@@ -53,7 +54,7 @@ List<Widget> buildUserOnboardingPages(BuildContext context, @@ -53,7 +54,7 @@ List<Widget> buildUserOnboardingPages(BuildContext context,
53 OnboardingOptionData( 54 OnboardingOptionData(
54 label: l10n.onboardingStateNone, 55 label: l10n.onboardingStateNone,
55 icon: Icons.more_horiz_rounded, 56 icon: Icons.more_horiz_rounded,
56 - iconColor: GuideCommonScaffold.brandColor, 57 + iconColor: context.colors.primary,
57 exclusive: true, 58 exclusive: true,
58 ), 59 ),
59 ], 60 ],
@@ -563,8 +564,8 @@ class _HealthPermissionPage extends StatelessWidget { @@ -563,8 +564,8 @@ class _HealthPermissionPage extends StatelessWidget {
563 child: Text( 564 child: Text(
564 l10n.onboardingHealthPermissionPrivacy, 565 l10n.onboardingHealthPermissionPrivacy,
565 textAlign: TextAlign.center, 566 textAlign: TextAlign.center,
566 - style: const TextStyle(  
567 - color: GuideCommonScaffold.subtitleColor, 567 + style: TextStyle(
  568 + color: context.colors.textSecondary,
568 fontSize: 12, 569 fontSize: 12,
569 height: 1.3, 570 height: 1.3,
570 letterSpacing: 0, 571 letterSpacing: 0,
@@ -661,8 +662,8 @@ class _MembershipOfferPage extends StatelessWidget { @@ -661,8 +662,8 @@ class _MembershipOfferPage extends StatelessWidget {
661 Text( 662 Text(
662 l10n.onboardingMemberCurrentPrice, 663 l10n.onboardingMemberCurrentPrice,
663 textAlign: TextAlign.center, 664 textAlign: TextAlign.center,
664 - style: const TextStyle(  
665 - color: GuideCommonScaffold.brandColor, 665 + style: TextStyle(
  666 + color: context.colors.primary,
666 fontSize: 28, 667 fontSize: 28,
667 fontWeight: FontWeight.w600, 668 fontWeight: FontWeight.w600,
668 letterSpacing: 0, 669 letterSpacing: 0,
@@ -682,11 +683,11 @@ class _MembershipOfferPage extends StatelessWidget { @@ -682,11 +683,11 @@ class _MembershipOfferPage extends StatelessWidget {
682 Text( 683 Text(
683 l10n.onboardingMemberAllOptions, 684 l10n.onboardingMemberAllOptions,
684 textAlign: TextAlign.center, 685 textAlign: TextAlign.center,
685 - style: const TextStyle(  
686 - color: GuideCommonScaffold.titleColor, 686 + style: TextStyle(
  687 + color: context.colors.textPrimary,
687 fontSize: 12, 688 fontSize: 12,
688 decoration: TextDecoration.underline, 689 decoration: TextDecoration.underline,
689 - decorationColor: GuideCommonScaffold.titleColor, 690 + decorationColor: context.colors.textPrimary,
690 letterSpacing: 0, 691 letterSpacing: 0,
691 ), 692 ),
692 ), 693 ),
@@ -11,10 +11,6 @@ class GuideCommonScaffold extends StatelessWidget { @@ -11,10 +11,6 @@ class GuideCommonScaffold extends StatelessWidget {
11 this.onBackPressed, 11 this.onBackPressed,
12 }); 12 });
13 13
14 - static const brandColor = Color(0xFF845EEE);  
15 - static const titleColor = Color(0xFF0F0F11);  
16 - static const subtitleColor = Color(0xFF78787D);  
17 -  
18 final Widget child; 14 final Widget child;
19 final Widget? bottom; 15 final Widget? bottom;
20 final List<Widget> actions; 16 final List<Widget> actions;
1 import 'dart:math' as math; 1 import 'dart:math' as math;
2 2
  3 +import 'package:doublefeel_flutter/core/theme/app_theme.dart';
3 import 'package:flutter/material.dart'; 4 import 'package:flutter/material.dart';
4 5
5 -import 'guide_common_scaffold.dart';  
6 -  
7 class OnboardingPageScrollBody extends StatelessWidget { 6 class OnboardingPageScrollBody extends StatelessWidget {
8 const OnboardingPageScrollBody({ 7 const OnboardingPageScrollBody({
9 super.key, 8 super.key,
@@ -53,8 +52,8 @@ class OnboardingTitleText extends StatelessWidget { @@ -53,8 +52,8 @@ class OnboardingTitleText extends StatelessWidget {
53 child: Text( 52 child: Text(
54 text, 53 text,
55 textAlign: TextAlign.center, 54 textAlign: TextAlign.center,
56 - style: const TextStyle(  
57 - color: GuideCommonScaffold.titleColor, 55 + style: TextStyle(
  56 + color: context.colors.textPrimary,
58 fontSize: 20, 57 fontSize: 20,
59 fontWeight: FontWeight.w600, 58 fontWeight: FontWeight.w600,
60 height: 1.3, 59 height: 1.3,
@@ -82,7 +81,7 @@ class OnboardingBodyText extends StatelessWidget { @@ -82,7 +81,7 @@ class OnboardingBodyText extends StatelessWidget {
82 text, 81 text,
83 textAlign: TextAlign.center, 82 textAlign: TextAlign.center,
84 style: TextStyle( 83 style: TextStyle(
85 - color: GuideCommonScaffold.titleColor, 84 + color: context.colors.textPrimary,
86 fontSize: fontSize, 85 fontSize: fontSize,
87 fontWeight: FontWeight.w400, 86 fontWeight: FontWeight.w400,
88 height: 1.38, 87 height: 1.38,
@@ -115,9 +114,9 @@ class OnboardingBottomButton extends StatelessWidget { @@ -115,9 +114,9 @@ class OnboardingBottomButton extends StatelessWidget {
115 child: ElevatedButton( 114 child: ElevatedButton(
116 onPressed: enabled ? onPressed : null, 115 onPressed: enabled ? onPressed : null,
117 style: ElevatedButton.styleFrom( 116 style: ElevatedButton.styleFrom(
118 - backgroundColor: GuideCommonScaffold.brandColor, 117 + backgroundColor: context.colors.primary,
119 disabledBackgroundColor: 118 disabledBackgroundColor:
120 - GuideCommonScaffold.brandColor.withValues(alpha: 0.4), 119 + context.colors.primary.withValues(alpha: 0.4),
121 foregroundColor: Colors.white, 120 foregroundColor: Colors.white,
122 disabledForegroundColor: Colors.white, 121 disabledForegroundColor: Colors.white,
123 elevation: 0, 122 elevation: 0,
@@ -211,7 +210,7 @@ class OnboardingPageIndicators extends StatelessWidget { @@ -211,7 +210,7 @@ class OnboardingPageIndicators extends StatelessWidget {
211 margin: const EdgeInsets.symmetric(horizontal: 2), 210 margin: const EdgeInsets.symmetric(horizontal: 2),
212 decoration: BoxDecoration( 211 decoration: BoxDecoration(
213 color: index == activeIndex 212 color: index == activeIndex
214 - ? GuideCommonScaffold.brandColor 213 + ? context.colors.primary
215 : const Color(0xFFD6C7FC), 214 : const Color(0xFFD6C7FC),
216 borderRadius: BorderRadius.circular(19), 215 borderRadius: BorderRadius.circular(19),
217 ), 216 ),
  1 +import 'package:doublefeel_flutter/core/theme/app_theme.dart';
1 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 2 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
2 import 'package:flutter/material.dart'; 3 import 'package:flutter/material.dart';
3 4
4 -import 'guide_common_scaffold.dart';  
5 -  
6 class OnboardingMotionPlaceholder extends StatelessWidget { 5 class OnboardingMotionPlaceholder extends StatelessWidget {
7 const OnboardingMotionPlaceholder({ 6 const OnboardingMotionPlaceholder({
8 super.key, 7 super.key,
@@ -36,7 +35,7 @@ class OnboardingMotionPlaceholder extends StatelessWidget { @@ -36,7 +35,7 @@ class OnboardingMotionPlaceholder extends StatelessWidget {
36 ), 35 ),
37 boxShadow: [ 36 boxShadow: [
38 BoxShadow( 37 BoxShadow(
39 - color: GuideCommonScaffold.brandColor.withValues(alpha: 0.1), 38 + color: context.colors.primary.withValues(alpha: 0.1),
40 blurRadius: 26, 39 blurRadius: 26,
41 offset: const Offset(0, 12), 40 offset: const Offset(0, 12),
42 ), 41 ),
@@ -44,7 +43,7 @@ class OnboardingMotionPlaceholder extends StatelessWidget { @@ -44,7 +43,7 @@ class OnboardingMotionPlaceholder extends StatelessWidget {
44 ), 43 ),
45 child: Icon( 44 child: Icon(
46 icon, 45 icon,
47 - color: GuideCommonScaffold.brandColor, 46 + color: context.colors.primary,
48 size: 72, 47 size: 72,
49 ), 48 ),
50 ), 49 ),
@@ -92,8 +91,8 @@ class ResearchCard extends StatelessWidget { @@ -92,8 +91,8 @@ class ResearchCard extends StatelessWidget {
92 Text( 91 Text(
93 title, 92 title,
94 textAlign: TextAlign.center, 93 textAlign: TextAlign.center,
95 - style: const TextStyle(  
96 - color: GuideCommonScaffold.titleColor, 94 + style: TextStyle(
  95 + color: context.colors.textPrimary,
97 fontSize: 18, 96 fontSize: 18,
98 fontWeight: FontWeight.w600, 97 fontWeight: FontWeight.w600,
99 letterSpacing: 0, 98 letterSpacing: 0,
@@ -106,8 +105,8 @@ class ResearchCard extends StatelessWidget { @@ -106,8 +105,8 @@ class ResearchCard extends StatelessWidget {
106 text: isHRVup 105 text: isHRVup
107 ? context.l10n.onboardingResearchHrvUp 106 ? context.l10n.onboardingResearchHrvUp
108 : context.l10n.onboardingResearchHrvDown, 107 : context.l10n.onboardingResearchHrvDown,
109 - style: const TextStyle(  
110 - color: GuideCommonScaffold.subtitleColor, 108 + style: TextStyle(
  109 + color: context.colors.textSecondary,
111 fontSize: 12, 110 fontSize: 12,
112 letterSpacing: 0)), 111 letterSpacing: 0)),
113 WidgetSpan( 112 WidgetSpan(
  1 +import 'package:doublefeel_flutter/core/theme/app_theme.dart';
1 import 'package:flutter/material.dart'; 2 import 'package:flutter/material.dart';
2 3
3 import '../models/onboarding_option_data.dart'; 4 import '../models/onboarding_option_data.dart';
4 -import 'guide_common_scaffold.dart';  
5 5
6 class OnboardingOptionTile extends StatelessWidget { 6 class OnboardingOptionTile extends StatelessWidget {
7 const OnboardingOptionTile({ 7 const OnboardingOptionTile({
@@ -34,16 +34,13 @@ class OnboardingOptionTile extends StatelessWidget { @@ -34,16 +34,13 @@ class OnboardingOptionTile extends StatelessWidget {
34 color: Colors.white, 34 color: Colors.white,
35 borderRadius: BorderRadius.circular(16), 35 borderRadius: BorderRadius.circular(16),
36 border: Border.all( 36 border: Border.all(
37 - color: selected  
38 - ? GuideCommonScaffold.brandColor  
39 - : Colors.transparent, 37 + color: selected ? context.colors.primary : Colors.transparent,
40 width: 1.5, 38 width: 1.5,
41 ), 39 ),
42 boxShadow: selected 40 boxShadow: selected
43 ? [ 41 ? [
44 BoxShadow( 42 BoxShadow(
45 - color:  
46 - GuideCommonScaffold.brandColor.withValues(alpha: 0.2), 43 + color: context.colors.primary.withValues(alpha: 0.2),
47 blurRadius: 6, 44 blurRadius: 6,
48 offset: const Offset(0, 5), 45 offset: const Offset(0, 5),
49 ), 46 ),
@@ -59,8 +56,8 @@ class OnboardingOptionTile extends StatelessWidget { @@ -59,8 +56,8 @@ class OnboardingOptionTile extends StatelessWidget {
59 data.label, 56 data.label,
60 style: TextStyle( 57 style: TextStyle(
61 color: selected 58 color: selected
62 - ? GuideCommonScaffold.brandColor  
63 - : GuideCommonScaffold.titleColor, 59 + ? context.colors.primary
  60 + : context.colors.textPrimary,
64 fontSize: 14, 61 fontSize: 14,
65 fontWeight: FontWeight.w500, 62 fontWeight: FontWeight.w500,
66 height: 1.25, 63 height: 1.25,
@@ -114,12 +111,10 @@ class _SelectionMark extends StatelessWidget { @@ -114,12 +111,10 @@ class _SelectionMark extends StatelessWidget {
114 duration: const Duration(milliseconds: 160), 111 duration: const Duration(milliseconds: 160),
115 curve: Curves.easeOut, 112 curve: Curves.easeOut,
116 decoration: BoxDecoration( 113 decoration: BoxDecoration(
117 - color: selected ? GuideCommonScaffold.brandColor : Colors.white, 114 + color: selected ? context.colors.primary : Colors.white,
118 shape: BoxShape.circle, 115 shape: BoxShape.circle,
119 border: Border.all( 116 border: Border.all(
120 - color: selected  
121 - ? GuideCommonScaffold.brandColor  
122 - : const Color(0xFFC9CAD5), 117 + color: selected ? context.colors.primary : const Color(0xFFC9CAD5),
123 ), 118 ),
124 ), 119 ),
125 child: selected 120 child: selected
1 import 'package:flutter/foundation.dart'; 1 import 'package:flutter/foundation.dart';
  2 +
2 import 'package:get/get.dart'; 3 import 'package:get/get.dart';
3 4
4 import '../modules/bind_partner/bindings/bind_partner_binding.dart'; 5 import '../modules/bind_partner/bindings/bind_partner_binding.dart';
5 import '../modules/bind_partner/views/bind_partner_view.dart'; 6 import '../modules/bind_partner/views/bind_partner_view.dart';
6 import '../modules/devtools/views/route_list_view.dart'; 7 import '../modules/devtools/views/route_list_view.dart';
  8 +import '../modules/feedback/feedback_list/bindings/feedback_list_binding.dart';
  9 +import '../modules/feedback/feedback_list/views/feedback_list_view.dart';
7 import '../modules/friends/bindings/add_friend_binding.dart'; 10 import '../modules/friends/bindings/add_friend_binding.dart';
8 import '../modules/friends/bindings/select_friend_binding.dart'; 11 import '../modules/friends/bindings/select_friend_binding.dart';
9 import '../modules/friends/views/add_friend_view.dart'; 12 import '../modules/friends/views/add_friend_view.dart';
10 import '../modules/friends/views/select_friend_view.dart'; 13 import '../modules/friends/views/select_friend_view.dart';
  14 +import '../modules/help/bindings/help_binding.dart';
  15 +import '../modules/help/views/help_view.dart';
11 import '../modules/home/bindings/home_binding.dart'; 16 import '../modules/home/bindings/home_binding.dart';
12 import '../modules/home/views/home_page.dart'; 17 import '../modules/home/views/home_page.dart';
13 import '../modules/login/bindings/login_binding.dart'; 18 import '../modules/login/bindings/login_binding.dart';
@@ -22,6 +27,8 @@ import '../modules/purchase/bindings/purchase_binding.dart'; @@ -22,6 +27,8 @@ import '../modules/purchase/bindings/purchase_binding.dart';
22 import '../modules/purchase/views/purchase_view.dart'; 27 import '../modules/purchase/views/purchase_view.dart';
23 import '../modules/splash/bindings/splash_binding.dart'; 28 import '../modules/splash/bindings/splash_binding.dart';
24 import '../modules/splash/views/splash_page.dart'; 29 import '../modules/splash/views/splash_page.dart';
  30 +import '../modules/feedback/submit_feedback/bindings/submit_feedback_binding.dart';
  31 +import '../modules/feedback/submit_feedback/views/submit_feedback_view.dart';
25 import '../modules/user_onboarding/bindings/user_onboarding_binding.dart'; 32 import '../modules/user_onboarding/bindings/user_onboarding_binding.dart';
26 import '../modules/user_onboarding/views/user_onboarding_view.dart'; 33 import '../modules/user_onboarding/views/user_onboarding_view.dart';
27 import '../modules/watch_theme/bindings/watch_theme_binding.dart'; 34 import '../modules/watch_theme/bindings/watch_theme_binding.dart';
@@ -38,7 +45,7 @@ part 'app_routes.dart'; @@ -38,7 +45,7 @@ part 'app_routes.dart';
38 45
39 /// Route path constants. 46 /// Route path constants.
40 abstract final class AppRoutes { 47 abstract final class AppRoutes {
41 - static const initial = Routes.ROUTE_LIST; 48 + static const initial = splash;
42 static const splash = '/splash'; 49 static const splash = '/splash';
43 static const login = '/login'; 50 static const login = '/login';
44 static const phoneLogin = '/phoneLogin'; 51 static const phoneLogin = '/phoneLogin';
@@ -50,7 +57,7 @@ abstract final class AppRoutes { @@ -50,7 +57,7 @@ abstract final class AppRoutes {
50 } 57 }
51 58
52 abstract final class AppPages { 59 abstract final class AppPages {
53 - static const initialRoute = Routes.ROUTE_LIST; 60 + static const initialRoute = AppRoutes.splash;
54 61
55 static final routes = [ 62 static final routes = [
56 GetPage( 63 GetPage(
@@ -124,6 +131,21 @@ abstract final class AppPages { @@ -124,6 +131,21 @@ abstract final class AppPages {
124 binding: PrivacySettingsBinding(), 131 binding: PrivacySettingsBinding(),
125 ), 132 ),
126 GetPage( 133 GetPage(
  134 + name: _Paths.HELP,
  135 + page: () => const HelpView(),
  136 + binding: HelpBinding(),
  137 + ),
  138 + GetPage(
  139 + name: _Paths.SUBMIT_FEEDBACK,
  140 + page: () => const SubmitFeedbackView(),
  141 + binding: SubmitFeedbackBinding(),
  142 + ),
  143 + GetPage(
  144 + name: _Paths.FEEDBACK_LIST,
  145 + page: () => const FeedbackListView(),
  146 + binding: FeedbackListBinding(),
  147 + ),
  148 + GetPage(
127 name: Routes.WATCH_THEME, 149 name: Routes.WATCH_THEME,
128 page: () => const WatchThemeView(), 150 page: () => const WatchThemeView(),
129 binding: WatchThemeBinding(), 151 binding: WatchThemeBinding(),
@@ -11,6 +11,9 @@ abstract class Routes { @@ -11,6 +11,9 @@ abstract class Routes {
11 static const SELECT_FRIEND = _Paths.SELECT_FRIEND; 11 static const SELECT_FRIEND = _Paths.SELECT_FRIEND;
12 static const PREMIUM_ACTIVATED = _Paths.PREMIUM_ACTIVATED; 12 static const PREMIUM_ACTIVATED = _Paths.PREMIUM_ACTIVATED;
13 static const PRIVACY_SETTINGS = _Paths.PRIVACY_SETTINGS; 13 static const PRIVACY_SETTINGS = _Paths.PRIVACY_SETTINGS;
  14 + static const HELP = _Paths.HELP;
  15 + static const SUBMIT_FEEDBACK = _Paths.SUBMIT_FEEDBACK;
  16 + static const FEEDBACK_LIST = _Paths.FEEDBACK_LIST;
14 static const WATCH_THEME = _Paths.WATCH_THEME; 17 static const WATCH_THEME = _Paths.WATCH_THEME;
15 static const WATCH_THEME_PREVIEW = _Paths.WATCH_THEME_PREVIEW; 18 static const WATCH_THEME_PREVIEW = _Paths.WATCH_THEME_PREVIEW;
16 static const WATCH_THEME_CREATE = _Paths.WATCH_THEME_CREATE; 19 static const WATCH_THEME_CREATE = _Paths.WATCH_THEME_CREATE;
@@ -28,6 +31,9 @@ abstract class _Paths { @@ -28,6 +31,9 @@ abstract class _Paths {
28 static const SELECT_FRIEND = '/select-friend'; 31 static const SELECT_FRIEND = '/select-friend';
29 static const PREMIUM_ACTIVATED = '/premium-activated'; 32 static const PREMIUM_ACTIVATED = '/premium-activated';
30 static const PRIVACY_SETTINGS = '/privacy-settings'; 33 static const PRIVACY_SETTINGS = '/privacy-settings';
  34 + static const HELP = '/help';
  35 + static const SUBMIT_FEEDBACK = '/submit-feedback';
  36 + static const FEEDBACK_LIST = '/feedback-list';
31 static const WATCH_THEME = '/watch-theme'; 37 static const WATCH_THEME = '/watch-theme';
32 static const WATCH_THEME_PREVIEW = '/watch-theme/preview'; 38 static const WATCH_THEME_PREVIEW = '/watch-theme/preview';
33 static const WATCH_THEME_CREATE = '/watch-theme/create'; 39 static const WATCH_THEME_CREATE = '/watch-theme/create';
@@ -190,4 +190,36 @@ class UserApi { @@ -190,4 +190,36 @@ class UserApi {
190 }, 190 },
191 ); 191 );
192 } 192 }
  193 +
  194 + Future<AppResult<void>> submitFeedback({
  195 + required String content,
  196 + String? email,
  197 + List<String>? images,
  198 + }) {
  199 + return safeCall(
  200 + call: () async {
  201 + var data = <String, dynamic>{
  202 + 'content': content,
  203 + };
  204 + if (email != null && email.isNotEmpty) {
  205 + data['email'] = email;
  206 + }
  207 + if (images != null && images.isNotEmpty) {
  208 + data['images'] = images;
  209 + }
  210 + await _dioClient.dio.post(ApiPaths.feedback, data: data);
  211 + },
  212 + );
  213 + }
  214 +
  215 + Future<AppResult<FeedbackListResponse>> getFeedbackList() {
  216 + return safeCall(
  217 + call: () async {
  218 + final response = await _dioClient.dio.get('${ApiPaths.feedback}list/');
  219 + return FeedbackListResponse.fromJson(
  220 + response.data as Map<String, dynamic>,
  221 + );
  222 + },
  223 + );
  224 + }
193 } 225 }
@@ -10,14 +10,17 @@ abstract final class ApiPaths { @@ -10,14 +10,17 @@ abstract final class ApiPaths {
10 static const userInfo = '/client/doublefeel/user/info/'; 10 static const userInfo = '/client/doublefeel/user/info/';
11 static const rongToken = '/client/doublefeel/rong/token/'; 11 static const rongToken = '/client/doublefeel/rong/token/';
12 static const userDevice = '/client/doublefeel/user/device/'; 12 static const userDevice = '/client/doublefeel/user/device/';
  13 + static const feedback = '/client/doublefeel/user/feedback/';
13 14
14 // Health 15 // Health
15 static const huaweiAuth = '/client/doublefeel/huawei/auth/'; 16 static const huaweiAuth = '/client/doublefeel/huawei/auth/';
16 static const healthLatestHrv = '/client/doublefeel/health/lastest_hrv/'; 17 static const healthLatestHrv = '/client/doublefeel/health/lastest_hrv/';
17 static const healthInfoToday = '/client/doublefeel/health/info_today/'; 18 static const healthInfoToday = '/client/doublefeel/health/info_today/';
18 static const healthPkInfo = '/client/doublefeel/health/pk_info/'; 19 static const healthPkInfo = '/client/doublefeel/health/pk_info/';
19 - static const healthUploadCommon = '/client/doublefeel/health/data_upload/common/';  
20 - static const healthUploadSleep = '/client/doublefeel/health/data_upload/sleep/'; 20 + static const healthUploadCommon =
  21 + '/client/doublefeel/health/data_upload/common/';
  22 + static const healthUploadSleep =
  23 + '/client/doublefeel/health/data_upload/sleep/';
21 static const healthStatsSleep = '/client/doublefeel/health/statistics/sleep/'; 24 static const healthStatsSleep = '/client/doublefeel/health/statistics/sleep/';
22 static const healthStatsActivity = 25 static const healthStatsActivity =
23 '/client/doublefeel/health/statistics/activity/'; 26 '/client/doublefeel/health/statistics/activity/';
@@ -3,6 +3,7 @@ import 'package:dio/dio.dart'; @@ -3,6 +3,7 @@ import 'package:dio/dio.dart';
3 import '../../constants/app_const.dart'; 3 import '../../constants/app_const.dart';
4 import '../../constants/network_const.dart'; 4 import '../../constants/network_const.dart';
5 import '../dio_extra.dart'; 5 import '../dio_extra.dart';
  6 +import '../user_agent_provider.dart';
6 import '../../../data/local/user_preferences_storage.dart'; 7 import '../../../data/local/user_preferences_storage.dart';
7 8
8 class TokenInterceptor extends Interceptor { 9 class TokenInterceptor extends Interceptor {
@@ -42,7 +43,11 @@ class TokenInterceptor extends Interceptor { @@ -42,7 +43,11 @@ class TokenInterceptor extends Interceptor {
42 handler.next(options); 43 handler.next(options);
43 } 44 }
44 45
  46 + /// 构建完整 User-Agent,格式与 Android 端 [DeviceInfoUtils.userAgent] 完全一致:
  47 + /// `{systemWebViewUA} doublefeel/{versionCode}({versionName})({manufacturer}##{brand}##{model}; Android{sdkInt}; {height}x{width})(huawei)`
  48 + ///
  49 + /// [UserAgentProvider.userAgent] 在 [AppBootstrap.init] 中已预取,此处同步读取。
45 String _buildUserAgent() { 50 String _buildUserAgent() {
46 - return '${AppConst.appName.toLowerCase()}/1(1.0.0)(flutter##app##device; Flutter; 0x0)(huawei)'; 51 + return UserAgentProvider.userAgent;
47 } 52 }
48 } 53 }
  1 +import '../../pigeon/platform_api.g.dart';
  2 +
  3 +/// App 启动时通过 [init] 经由 Pigeon 从 native 获取完整 User-Agent 并缓存,
  4 +/// 供 [TokenInterceptor] 等拦截器同步读取。
  5 +///
  6 +/// UA 由 native 侧完整组装(Android / iOS),格式:
  7 +/// `{systemWebViewUA} doublefeel/{versionCode}({versionName})({manufacturer}##{brand}##{model}; {OS}{osVersion}; {height}x{width})(huawei)`
  8 +abstract final class UserAgentProvider {
  9 + static String _userAgent = 'doublefeel';
  10 +
  11 + /// 完整的 User-Agent 字符串。[init] 完成前返回占位值。
  12 + static String get userAgent => _userAgent;
  13 +
  14 + /// 在 [AppBootstrap.init] 中 await,确保首次网络请求前 UA 已就绪。
  15 + static Future<void> init() async {
  16 + try {
  17 + _userAgent = await PlatformHostApi().getFullUserAgent();
  18 + } catch (_) {
  19 + // native 获取失败时保留占位值,不阻塞启动
  20 + }
  21 + }
  22 +}
@@ -42,6 +42,7 @@ class AppColors { @@ -42,6 +42,7 @@ class AppColors {
42 // ========================================== 42 // ==========================================
43 static const backgroundLight = Color(0xFFFAFAFE); 43 static const backgroundLight = Color(0xFFFAFAFE);
44 static const brandBackgroundLight = Color(0xFFEAE3FF); 44 static const brandBackgroundLight = Color(0xFFEAE3FF);
  45 + static const backgroundPage = Color(0xFFF5F2FF);
45 static const warning = Color(0xFFFC4447); 46 static const warning = Color(0xFFFC4447);
46 // ========================================== 47 // ==========================================
47 // 渐变基础色 (Gradient Components) 48 // 渐变基础色 (Gradient Components)
@@ -36,6 +36,8 @@ class AppColorsExtension extends ThemeExtension<AppColorsExtension> { @@ -36,6 +36,8 @@ class AppColorsExtension extends ThemeExtension<AppColorsExtension> {
36 36
37 final Color warning; 37 final Color warning;
38 38
  39 + final Color backgroundPage;
  40 +
39 const AppColorsExtension({ 41 const AppColorsExtension({
40 required this.primary, 42 required this.primary,
41 required this.textPrimary, 43 required this.textPrimary,
@@ -56,6 +58,7 @@ class AppColorsExtension extends ThemeExtension<AppColorsExtension> { @@ -56,6 +58,7 @@ class AppColorsExtension extends ThemeExtension<AppColorsExtension> {
56 required this.brandBackgroundLight, 58 required this.brandBackgroundLight,
57 required this.brandBackgroundGradient, 59 required this.brandBackgroundGradient,
58 required this.warning, 60 required this.warning,
  61 + required this.backgroundPage,
59 }); 62 });
60 63
61 /// The standard light palette derived directly from Figma. 64 /// The standard light palette derived directly from Figma.
@@ -83,7 +86,8 @@ class AppColorsExtension extends ThemeExtension<AppColorsExtension> { @@ -83,7 +86,8 @@ class AppColorsExtension extends ThemeExtension<AppColorsExtension> {
83 begin: Alignment.centerLeft, 86 begin: Alignment.centerLeft,
84 end: Alignment.centerRight, 87 end: Alignment.centerRight,
85 ), 88 ),
86 - warning: Color(0xFFFC4447), 89 + warning: AppColors.warning,
  90 + backgroundPage: AppColors.backgroundPage,
87 ); 91 );
88 } 92 }
89 93
@@ -113,6 +117,7 @@ class AppColorsExtension extends ThemeExtension<AppColorsExtension> { @@ -113,6 +117,7 @@ class AppColorsExtension extends ThemeExtension<AppColorsExtension> {
113 end: Alignment.centerRight, 117 end: Alignment.centerRight,
114 ), 118 ),
115 warning: AppColors.warning, 119 warning: AppColors.warning,
  120 + backgroundPage: AppColors.backgroundPage,
116 ); 121 );
117 } 122 }
118 123
@@ -137,6 +142,7 @@ class AppColorsExtension extends ThemeExtension<AppColorsExtension> { @@ -137,6 +142,7 @@ class AppColorsExtension extends ThemeExtension<AppColorsExtension> {
137 Color? brandBackgroundLight, 142 Color? brandBackgroundLight,
138 LinearGradient? brandBackgroundGradient, 143 LinearGradient? brandBackgroundGradient,
139 Color? warning, 144 Color? warning,
  145 + Color? backgroundPage,
140 }) { 146 }) {
141 return AppColorsExtension( 147 return AppColorsExtension(
142 primary: primary ?? this.primary, 148 primary: primary ?? this.primary,
@@ -159,6 +165,7 @@ class AppColorsExtension extends ThemeExtension<AppColorsExtension> { @@ -159,6 +165,7 @@ class AppColorsExtension extends ThemeExtension<AppColorsExtension> {
159 brandBackgroundGradient: 165 brandBackgroundGradient:
160 brandBackgroundGradient ?? this.brandBackgroundGradient, 166 brandBackgroundGradient ?? this.brandBackgroundGradient,
161 warning: warning ?? this.warning, 167 warning: warning ?? this.warning,
  168 + backgroundPage: backgroundPage ?? this.backgroundPage,
162 ); 169 );
163 } 170 }
164 171
@@ -189,6 +196,7 @@ class AppColorsExtension extends ThemeExtension<AppColorsExtension> { @@ -189,6 +196,7 @@ class AppColorsExtension extends ThemeExtension<AppColorsExtension> {
189 brandBackgroundGradient: LinearGradient.lerp( 196 brandBackgroundGradient: LinearGradient.lerp(
190 brandBackgroundGradient, other.brandBackgroundGradient, t)!, 197 brandBackgroundGradient, other.brandBackgroundGradient, t)!,
191 warning: Color.lerp(warning, other.warning, t)!, 198 warning: Color.lerp(warning, other.warning, t)!,
  199 + backgroundPage: Color.lerp(backgroundPage, other.backgroundPage, t)!,
192 ); 200 );
193 } 201 }
194 } 202 }
@@ -22,19 +22,21 @@ class AppTheme { @@ -22,19 +22,21 @@ class AppTheme {
22 useMaterial3: true, 22 useMaterial3: true,
23 brightness: Brightness.light, 23 brightness: Brightness.light,
24 primaryColor: colors.primary, 24 primaryColor: colors.primary,
25 - scaffoldBackgroundColor: colors.backgroundLight, 25 + scaffoldBackgroundColor: colors.backgroundPage,
26 26
27 // Clean modern AppBar theme using Figma colors 27 // Clean modern AppBar theme using Figma colors
28 appBarTheme: AppBarTheme( 28 appBarTheme: AppBarTheme(
29 - backgroundColor: colors.backgroundLight, 29 + backgroundColor: Colors.transparent,
  30 + surfaceTintColor: Colors.transparent,
30 elevation: 0, 31 elevation: 0,
  32 + toolbarHeight: 44,
31 centerTitle: true, 33 centerTitle: true,
32 iconTheme: IconThemeData(color: colors.textPrimary), 34 iconTheme: IconThemeData(color: colors.textPrimary),
33 actionsIconTheme: IconThemeData(color: colors.textPrimary), 35 actionsIconTheme: IconThemeData(color: colors.textPrimary),
34 titleTextStyle: TextStyle( 36 titleTextStyle: TextStyle(
35 - color: colors.textPrimary,  
36 - fontSize: 18,  
37 - fontWeight: FontWeight.w600, 37 + color: Colors.black,
  38 + fontSize: 16,
  39 + fontWeight: FontWeight.w500,
38 ), 40 ),
39 systemOverlayStyle: systemUiOverlayStyle, 41 systemOverlayStyle: systemUiOverlayStyle,
40 ), 42 ),
@@ -66,15 +68,17 @@ class AppTheme { @@ -66,15 +68,17 @@ class AppTheme {
66 primaryColor: colors.primary, 68 primaryColor: colors.primary,
67 scaffoldBackgroundColor: colors.backgroundLight, 69 scaffoldBackgroundColor: colors.backgroundLight,
68 appBarTheme: AppBarTheme( 70 appBarTheme: AppBarTheme(
69 - backgroundColor: colors.backgroundLight, 71 + backgroundColor: Colors.transparent,
  72 + surfaceTintColor: Colors.transparent,
70 elevation: 0, 73 elevation: 0,
  74 + toolbarHeight: 44,
71 centerTitle: true, 75 centerTitle: true,
72 iconTheme: IconThemeData(color: colors.textPrimary), 76 iconTheme: IconThemeData(color: colors.textPrimary),
73 actionsIconTheme: IconThemeData(color: colors.textPrimary), 77 actionsIconTheme: IconThemeData(color: colors.textPrimary),
74 titleTextStyle: TextStyle( 78 titleTextStyle: TextStyle(
75 - color: colors.textPrimary,  
76 - fontSize: 18,  
77 - fontWeight: FontWeight.w600, 79 + color: Colors.black,
  80 + fontSize: 16,
  81 + fontWeight: FontWeight.w500,
78 ), 82 ),
79 systemOverlayStyle: systemUiOverlayStyle, 83 systemOverlayStyle: systemUiOverlayStyle,
80 ), 84 ),
@@ -130,7 +130,8 @@ class LoginResponse { @@ -130,7 +130,8 @@ class LoginResponse {
130 isNewUser: json['is_new_user'] as bool?, 130 isNewUser: json['is_new_user'] as bool?,
131 accessTokenInfo: json['token_info'] == null 131 accessTokenInfo: json['token_info'] == null
132 ? null 132 ? null
133 - : UserAccessToken.fromJson(json['token_info'] as Map<String, dynamic>), 133 + : UserAccessToken.fromJson(
  134 + json['token_info'] as Map<String, dynamic>),
134 id: json['id'] as int?, 135 id: json['id'] as int?,
135 ); 136 );
136 } 137 }
@@ -155,7 +156,8 @@ class RegisterResponse { @@ -155,7 +156,8 @@ class RegisterResponse {
155 return RegisterResponse( 156 return RegisterResponse(
156 accessTokenInfo: json['token_info'] == null 157 accessTokenInfo: json['token_info'] == null
157 ? null 158 ? null
158 - : UserAccessToken.fromJson(json['token_info'] as Map<String, dynamic>), 159 + : UserAccessToken.fromJson(
  160 + json['token_info'] as Map<String, dynamic>),
159 ); 161 );
160 } 162 }
161 163
@@ -236,7 +238,8 @@ class BoundUserInfoResponse { @@ -236,7 +238,8 @@ class BoundUserInfoResponse {
236 return BoundUserInfoResponse( 238 return BoundUserInfoResponse(
237 userInfo: json['user_info'] == null 239 userInfo: json['user_info'] == null
238 ? null 240 ? null
239 - : UserInfoResponse.fromJson(json['user_info'] as Map<String, dynamic>), 241 + : UserInfoResponse.fromJson(
  242 + json['user_info'] as Map<String, dynamic>),
240 partnerUserInfo: json['pair_user_info'] == null 243 partnerUserInfo: json['pair_user_info'] == null
241 ? null 244 ? null
242 : UserInfoResponse.fromJson( 245 : UserInfoResponse.fromJson(
@@ -271,3 +274,75 @@ class RongcloudTokenResponse { @@ -271,3 +274,75 @@ class RongcloudTokenResponse {
271 return val; 274 return val;
272 } 275 }
273 } 276 }
  277 +
  278 +class FeedbackListResponse {
  279 + const FeedbackListResponse({this.records});
  280 +
  281 + final List<FeedbackRecord>? records;
  282 +
  283 + factory FeedbackListResponse.fromJson(Map<String, dynamic> json) {
  284 + return FeedbackListResponse(
  285 + records: (json['records'] as List<dynamic>?)
  286 + ?.map((e) => FeedbackRecord.fromJson(e as Map<String, dynamic>))
  287 + .toList(),
  288 + );
  289 + }
  290 +
  291 + Map<String, dynamic> toJson() {
  292 + final val = <String, dynamic>{};
  293 + if (records != null) {
  294 + val['records'] = records!.map((e) => e.toJson()).toList();
  295 + }
  296 + return val;
  297 + }
  298 +}
  299 +
  300 +class FeedbackRecord {
  301 + const FeedbackRecord({
  302 + this.id,
  303 + this.userId,
  304 + this.content,
  305 + this.images = const [],
  306 + this.createTime,
  307 + this.reply,
  308 + this.replyTime,
  309 + this.replyUserRead,
  310 + });
  311 +
  312 + final int? id;
  313 + final int? userId;
  314 + final String? content;
  315 + final List<String> images;
  316 + final int? createTime;
  317 + final String? reply;
  318 + final int? replyTime;
  319 + final int? replyUserRead;
  320 +
  321 + factory FeedbackRecord.fromJson(Map<String, dynamic> json) {
  322 + return FeedbackRecord(
  323 + id: json['id'] as int?,
  324 + userId: json['user_id'] as int?,
  325 + content: json['content'] as String?,
  326 + images:
  327 + (json['images'] as List<dynamic>?)?.whereType<String>().toList() ??
  328 + const [],
  329 + createTime: json['create_time'] as int?,
  330 + reply: json['reply'] as String?,
  331 + replyTime: json['reply_time'] as int?,
  332 + replyUserRead: json['reply_user_read'] as int?,
  333 + );
  334 + }
  335 +
  336 + Map<String, dynamic> toJson() {
  337 + final val = <String, dynamic>{};
  338 + if (id != null) val['id'] = id;
  339 + if (userId != null) val['user_id'] = userId;
  340 + if (content != null) val['content'] = content;
  341 + val['images'] = images;
  342 + if (createTime != null) val['create_time'] = createTime;
  343 + if (reply != null) val['reply'] = reply;
  344 + if (replyTime != null) val['reply_time'] = replyTime;
  345 + if (replyUserRead != null) val['reply_user_read'] = replyUserRead;
  346 + return val;
  347 + }
  348 +}
  1 +// // Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Autogenerated from Pigeon (v25.5.0), do not edit directly.
  3 +// See also: https://pub.dev/packages/pigeon
  4 +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
  5 +
  6 +import 'dart:async';
  7 +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
  8 +
  9 +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
  10 +import 'package:flutter/services.dart';
  11 +
  12 +PlatformException _createConnectionError(String channelName) {
  13 + return PlatformException(
  14 + code: 'channel-error',
  15 + message: 'Unable to establish connection on channel: "$channelName".',
  16 + );
  17 +}
  18 +
  19 +
  20 +class _PigeonCodec extends StandardMessageCodec {
  21 + const _PigeonCodec();
  22 + @override
  23 + void writeValue(WriteBuffer buffer, Object? value) {
  24 + if (value is int) {
  25 + buffer.putUint8(4);
  26 + buffer.putInt64(value);
  27 + } else {
  28 + super.writeValue(buffer, value);
  29 + }
  30 + }
  31 +
  32 + @override
  33 + Object? readValueOfType(int type, ReadBuffer buffer) {
  34 + switch (type) {
  35 + default:
  36 + return super.readValueOfType(type, buffer);
  37 + }
  38 + }
  39 +}
  40 +
  41 +class PlatformHostApi {
  42 + /// Constructor for [PlatformHostApi]. The [binaryMessenger] named argument is
  43 + /// available for dependency injection. If it is left null, the default
  44 + /// BinaryMessenger will be used which routes to the host platform.
  45 + PlatformHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
  46 + : pigeonVar_binaryMessenger = binaryMessenger,
  47 + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
  48 + final BinaryMessenger? pigeonVar_binaryMessenger;
  49 +
  50 + static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
  51 +
  52 + final String pigeonVar_messageChannelSuffix;
  53 +
  54 + /// 返回完整的 User-Agent 字符串,由 native 侧组装:
  55 + /// `{systemWebViewUA} {appName}/{versionCode}({versionName})({manufacturer}##{brand}##{model}; {OS}{osVersion}; {height}x{width})(huawei)`
  56 + Future<String> getFullUserAgent() async {
  57 + final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.PlatformHostApi.getFullUserAgent$pigeonVar_messageChannelSuffix';
  58 + final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
  59 + pigeonVar_channelName,
  60 + pigeonChannelCodec,
  61 + binaryMessenger: pigeonVar_binaryMessenger,
  62 + );
  63 + final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
  64 + final List<Object?>? pigeonVar_replyList =
  65 + await pigeonVar_sendFuture as List<Object?>?;
  66 + if (pigeonVar_replyList == null) {
  67 + throw _createConnectionError(pigeonVar_channelName);
  68 + } else if (pigeonVar_replyList.length > 1) {
  69 + throw PlatformException(
  70 + code: pigeonVar_replyList[0]! as String,
  71 + message: pigeonVar_replyList[1] as String?,
  72 + details: pigeonVar_replyList[2],
  73 + );
  74 + } else if (pigeonVar_replyList[0] == null) {
  75 + throw PlatformException(
  76 + code: 'null-error',
  77 + message: 'Host platform returned null value for non-null return value.',
  78 + );
  79 + } else {
  80 + return (pigeonVar_replyList[0] as String?)!;
  81 + }
  82 + }
  83 +}
  1 +import { PlatformHostApi } from '../pigeon/PlatformApi';
  2 +import { bundleManager } from '@kit.AbilityKit';
  3 +import { deviceInfo } from '@kit.BasicServicesKit';
  4 +import { display } from '@kit.ArkUI';
  5 +import { web_webview } from '@kit.ArkWeb';
  6 +
  7 +/**
  8 + * PlatformApi HarmonyOS implementation.
  9 + *
  10 + * 组装完整 User-Agent,格式与 Android 端保持一致:
  11 + * `{systemWebViewUA} doublefeel/{versionCode}({versionName})({manufacturer}##{brand}##{model}; OpenHarmony{osVersion}; {height}x{width})(huawei)`
  12 + */
  13 +export class PlatformHostApiImpl extends PlatformHostApi {
  14 +
  15 + getFullUserAgent(): string {
  16 + // 1. 系统 WebView UA
  17 + let systemUa = '';
  18 + try {
  19 + systemUa = web_webview.WebviewController.getDefaultUserAgent();
  20 + } catch (_) {
  21 + systemUa = '';
  22 + }
  23 +
  24 + // 2. 版本信息(同步读取 bundleInfo)
  25 + let versionCode = 0;
  26 + let versionName = '';
  27 + try {
  28 + const bundleInfo = bundleManager.getBundleInfoForSelfSync(
  29 + bundleManager.BundleFlag.GET_BUNDLE_INFO_DEFAULT
  30 + );
  31 + versionCode = bundleInfo.versionCode;
  32 + versionName = bundleInfo.versionName;
  33 + } catch (_) {}
  34 +
  35 + // 3. 设备信息(@ohos.deviceInfo 常量,无需异步)
  36 + const manufacturer: string = deviceInfo.manufacture ?? '';
  37 + const brand: string = deviceInfo.brand ?? '';
  38 + const model: string = deviceInfo.productModel ?? '';
  39 + // osFullName 示例:"OpenHarmony 4.1.0",取主版本号数字部分
  40 + const osVersion: string = deviceInfo.osFullName.replace(/[^0-9]/g, '') ?? '';
  41 +
  42 + // 4. 屏幕物理分辨率:长边为 height,短边为 width
  43 + let screenWidth = 0;
  44 + let screenHeight = 0;
  45 + try {
  46 + const defaultDisplay = display.getDefaultDisplaySync();
  47 + const w = defaultDisplay.width;
  48 + const h = defaultDisplay.height;
  49 + screenWidth = Math.min(w, h);
  50 + screenHeight = Math.max(w, h);
  51 + } catch (_) {}
  52 +
  53 + // 5. 组装 customAgent(与 Android DeviceInfoUtils.userAgent 格式一致)
  54 + const customAgent =
  55 + `doublefeel/${versionCode}(${versionName})` +
  56 + `(${manufacturer}##${brand}##${model}; OpenHarmony${osVersion}; ${screenHeight}x${screenWidth})` +
  57 + `(huawei)`;
  58 +
  59 + const full = `${systemUa.trim()} ${customAgent}`.trim();
  60 + return full;
  61 + }
  62 +}
  1 +import 'package:pigeon/pigeon.dart';
  2 +
  3 +@ConfigurePigeon(
  4 + PigeonOptions(
  5 + dartOut: 'lib/pigeon/platform_api.g.dart',
  6 + dartPackageName: 'doublefeel_flutter',
  7 + dartOptions: DartOptions(),
  8 + kotlinOut:
  9 + 'android/app/src/main/kotlin/com/doublefeel/app/pigeon/PlatformApi.kt',
  10 + kotlinOptions: KotlinOptions(
  11 + package: 'com.doublefeel.app.pigeon.platform',
  12 + ),
  13 + swiftOut: 'ios/Runner/Pigeon/PlatformApi.swift',
  14 + swiftOptions: SwiftOptions(),
  15 + arkTSOut: 'ohos/entry/src/main/ets/pigeon/PlatformApi.ets',
  16 + copyrightHeader: 'pigeon/copyright.txt',
  17 + ),
  18 +)
  19 +@HostApi()
  20 +abstract class PlatformHostApi {
  21 + /// 返回完整的 User-Agent 字符串,由 native 侧组装:
  22 + /// `{systemWebViewUA} {appName}/{versionCode}({versionName})({manufacturer}##{brand}##{model}; {OS}{osVersion}; {height}x{width})(huawei)`
  23 + String getFullUserAgent();
  24 +}
@@ -2,7 +2,7 @@ name: doublefeel_flutter @@ -2,7 +2,7 @@ name: doublefeel_flutter
2 description: "A new Flutter project." 2 description: "A new Flutter project."
3 publish_to: 'none' 3 publish_to: 'none'
4 4
5 -version: 1.0.0+1 5 +version: 2.5.0+100
6 6
7 environment: 7 environment:
8 sdk: ^3.6.2 8 sdk: ^3.6.2
@@ -42,11 +42,11 @@ dependencies: @@ -42,11 +42,11 @@ dependencies:
42 url: https://gitcode.com/openharmony-sig/flutter_permission_handler.git 42 url: https://gitcode.com/openharmony-sig/flutter_permission_handler.git
43 path: permission_handler_ohos 43 path: permission_handler_ohos
44 ref: br_permission_handler_v11.3.1_ohos 44 ref: br_permission_handler_v11.3.1_ohos
45 - intl: ^0.19.0  
46 - flutter_localizations:  
47 - sdk: flutter  
48 table_calendar: ^3.1.3 45 table_calendar: ^3.1.3
49 fluttertoast: ^8.2.2 46 fluttertoast: ^8.2.2
  47 + flutter_localizations:
  48 + sdk: flutter
  49 + intl: any
50 50
51 dev_dependencies: 51 dev_dependencies:
52 flutter_test: 52 flutter_test:
@@ -70,4 +70,5 @@ flutter: @@ -70,4 +70,5 @@ flutter:
70 - assets/images/today/ 70 - assets/images/today/
71 - assets/images/friends/ 71 - assets/images/friends/
72 - assets/images/my/ 72 - assets/images/my/
  73 + - assets/images/my/
73 - assets/images/watch_theme/ 74 - assets/images/watch_theme/
@@ -5,6 +5,8 @@ cd "$(dirname "$0")/.." @@ -5,6 +5,8 @@ cd "$(dirname "$0")/.."
5 dart run pigeon --input pigeon/health_kit_api.dart 5 dart run pigeon --input pigeon/health_kit_api.dart
6 dart run pigeon --input pigeon/wear_engine_api.dart 6 dart run pigeon --input pigeon/wear_engine_api.dart
7 dart run pigeon --input pigeon/alipay_api.dart 7 dart run pigeon --input pigeon/alipay_api.dart
  8 +dart run pigeon --input pigeon/platform_api.dart
  9 +
8 10
9 # Pigeon emits PigeonError in every swiftOut file; Runner needs a single definition. 11 # Pigeon emits PigeonError in every swiftOut file; Runner needs a single definition.
10 python3 <<'PY' 12 python3 <<'PY'