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