Commit 3321b51a7ba1a7d03f9c3adfb0a3aa3b1cbd50d7

Authored by 权海
1 parent 7b5acdb7

feat(ui):增加我的-watch主题

Showing 53 changed files with 3221 additions and 80 deletions
... ... @@ -8,8 +8,7 @@
"name": "Flutter",
"type": "dart",
"request": "launch",
"program": "lib/main.dart",
"flutterMode": "profile"
"program": "lib/main.dart"
},
]
}
\ No newline at end of file
... ...
... ... @@ -172,7 +172,6 @@
2ECF3C9772810B1582D524F4 /* Pods-RunnerTests.release.xcconfig */,
5620C49637B3BA24A7B2A069 /* Pods-RunnerTests.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
... ... @@ -524,7 +523,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.luiz.doublefeel;
PRODUCT_BUNDLE_IDENTIFIER = com.luiz.doublefeel.flutter;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
... ... @@ -707,7 +706,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.luiz.doublefeel;
PRODUCT_BUNDLE_IDENTIFIER = com.luiz.doublefeel.flutter;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
... ... @@ -730,7 +729,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.luiz.doublefeel;
PRODUCT_BUNDLE_IDENTIFIER = com.luiz.doublefeel.flutter;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
... ...
... ... @@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
... ... @@ -24,6 +26,10 @@
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSPhotoLibraryUsageDescription</key>
<string>用于选择图片并生成自定义 Watch 表盘主题。</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
... ... @@ -41,9 +47,5 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>
... ...
import Foundation
import Flutter
import Foundation
import PhotosUI
import UIKit
import Vision
private let unsupported = "HealthKit / WearEngine / Alipay are not supported on iOS in this build."
... ... @@ -19,6 +22,12 @@ final class WearEngineHostApiStub: WearEngineHostApi {
func registerMessageReceiver() throws -> Bool { false }
func sendTextMessage(message: String) throws -> Bool { false }
func sendWatchSyncPayload(jsonPayload: String) throws -> Bool { false }
func pickImageAndRemoveBackground() throws -> String? {
if #available(iOS 14.0, *) {
return WatchThemeImagePicker().pickImageAndRemoveBackground()
}
return nil
}
}
final class AlipayHostApiStub: AlipayHostApi {
... ... @@ -32,3 +41,197 @@ enum NativePigeonRegistrar {
AlipayHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: AlipayHostApiStub())
}
}
@available(iOS 14.0, *)
private final class WatchThemeImagePicker: NSObject, PHPickerViewControllerDelegate {
private var continuation: CheckedContinuation<String?, Never>?
func pickImageAndRemoveBackground() -> String? {
if Thread.isMainThread {
return runOnMain()
}
var result: String?
let semaphore = DispatchSemaphore(value: 0)
DispatchQueue.main.async {
result = self.runOnMain()
semaphore.signal()
}
semaphore.wait()
return result
}
private func runOnMain() -> String? {
var configuration = PHPickerConfiguration(photoLibrary: .shared())
configuration.filter = .images
configuration.selectionLimit = 1
guard let presenter = UIApplication.shared.topMostViewController else {
return nil
}
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = self
return waitForPickerResult(picker: picker, presenter: presenter)
}
private func waitForPickerResult(
picker: PHPickerViewController,
presenter: UIViewController
) -> String? {
var pickedPath: String?
let semaphore = DispatchSemaphore(value: 0)
Task { @MainActor in
pickedPath = await withCheckedContinuation { continuation in
self.continuation = continuation
presenter.present(picker, animated: true)
}
semaphore.signal()
}
while semaphore.wait(timeout: .now() + 0.05) == .timedOut {
RunLoop.main.run(mode: .default, before: Date(timeIntervalSinceNow: 0.05))
}
return pickedPath
}
nonisolated func picker(
_ picker: PHPickerViewController,
didFinishPicking results: [PHPickerResult]
) {
Task { @MainActor in
picker.dismiss(animated: true)
guard let provider = results.first?.itemProvider,
provider.canLoadObject(ofClass: UIImage.self) else {
continuation?.resume(returning: nil)
continuation = nil
return
}
provider.loadObject(ofClass: UIImage.self) { object, _ in
Task { @MainActor in
guard let image = object as? UIImage else {
self.continuation?.resume(returning: nil)
self.continuation = nil
return
}
let processed = await WatchThemeImageProcessor.removeBackground(from: image)
let path = WatchThemeImageProcessor.savePNG(processed)
self.continuation?.resume(returning: path)
self.continuation = nil
}
}
}
}
}
private enum WatchThemeImageProcessor {
static func removeBackground(from image: UIImage) async -> UIImage {
guard #available(iOS 17.0, *),
let cgImage = image.normalizedCGImage else {
return image
}
return await Task.detached(priority: .userInitiated) {
let request = VNGenerateForegroundInstanceMaskRequest()
let handler = VNImageRequestHandler(cgImage: cgImage)
do {
try handler.perform([request])
guard let observation = request.results?.first else {
return image
}
let mask = try observation.generateScaledMaskForImage(
forInstances: observation.allInstances,
from: handler
)
return composite(image: cgImage, mask: mask) ?? image
} catch {
return image
}
}.value
}
static func savePNG(_ image: UIImage) -> String? {
guard let data = image.pngData() else { return nil }
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("watch_theme", isDirectory: true)
do {
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true
)
let file = directory.appendingPathComponent("\(UUID().uuidString).png")
try data.write(to: file, options: .atomic)
return file.path
} catch {
return nil
}
}
private static func composite(image: CGImage, mask: CVPixelBuffer) -> UIImage? {
let ciImage = CIImage(cgImage: image)
let ciMask = CIImage(cvPixelBuffer: mask)
guard let filter = CIFilter(name: "CIBlendWithMask") else {
return nil
}
filter.setValue(ciImage, forKey: kCIInputImageKey)
filter.setValue(ciMask, forKey: kCIInputMaskImageKey)
filter.setValue(
CIImage(color: .clear).cropped(to: ciImage.extent),
forKey: kCIInputBackgroundImageKey
)
guard let output = filter.outputImage,
let cgOutput = CIContext().createCGImage(output, from: ciImage.extent) else {
return nil
}
return UIImage(cgImage: cgOutput, scale: 1, orientation: .up)
}
}
private extension UIImage {
var normalizedCGImage: CGImage? {
if imageOrientation == .up, let cgImage {
return cgImage
}
let format = UIGraphicsImageRendererFormat.default()
format.scale = scale
let renderer = UIGraphicsImageRenderer(size: size, format: format)
return renderer.image { _ in
draw(in: CGRect(origin: .zero, size: size))
}.cgImage
}
}
private extension UIApplication {
var topMostViewController: UIViewController? {
connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap(\.windows)
.first { $0.isKeyWindow }?
.rootViewController?
.topMostPresented
}
}
private extension UIViewController {
var topMostPresented: UIViewController {
if let presentedViewController {
return presentedViewController.topMostPresented
}
if let navigationController = self as? UINavigationController {
return navigationController.visibleViewController?.topMostPresented ?? navigationController
}
if let tabBarController = self as? UITabBarController {
return tabBarController.selectedViewController?.topMostPresented ?? tabBarController
}
return self
}
}
... ...
... ... @@ -188,6 +188,7 @@ protocol WearEngineHostApi {
func registerMessageReceiver() throws -> Bool
func sendTextMessage(message: String) throws -> Bool
func sendWatchSyncPayload(jsonPayload: String) throws -> Bool
func pickImageAndRemoveBackground() throws -> String?
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
... ... @@ -265,5 +266,18 @@ class WearEngineHostApiSetup {
} else {
sendWatchSyncPayloadChannel.setMessageHandler(nil)
}
let pickImageAndRemoveBackgroundChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.WearEngineHostApi.pickImageAndRemoveBackground\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
pickImageAndRemoveBackgroundChannel.setMessageHandler { _, reply in
do {
let result = try api.pickImageAndRemoveBackground()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
pickImageAndRemoveBackgroundChannel.setMessageHandler(nil)
}
}
}
... ...
import 'package:get/get.dart';
import '../controllers/account_settings_controller.dart';
class AccountSettingsBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<AccountSettingsController>(() => AccountSettingsController());
}
}
... ...
import 'package:doublefeel_flutter/app/routes/app_pages.dart';
import 'package:doublefeel_flutter/core/services/user_state_service.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:get/get.dart';
class AccountSettingsController extends GetxController {
final UserPreferencesStorage _userPrefs = Get.find<UserPreferencesStorage>();
final UserStateService _userStateService = Get.find<UserStateService>();
UserPreferencesStorage get userPrefs => _userPrefs;
void executeBackLogic() {
Get.back();
}
Future<void> logout() async {
await _userStateService.onLogout();
Get.offAllNamed(AppRoutes.initial);
}
void openOnboarding() {
Get.offAllNamed(AppRoutes.userOnboarding);
}
}
... ...
import 'package:doublefeel_flutter/core/theme/app_colors.dart';
import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:doublefeel_flutter/data/models/user/user_models.dart';
import 'package:doublefeel_flutter/r.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import '../controllers/account_settings_controller.dart';
class AccountSettingsView extends GetView<AccountSettingsController> {
const AccountSettingsView({super.key});
static const _background = Color(0xFFF5F2FF);
static const _danger = Color(0xFFFC4447);
@override
Widget build(BuildContext context) {
return AnnotatedRegion<SystemUiOverlayStyle>(
value: const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
systemNavigationBarColor: _background,
systemNavigationBarIconBrightness: Brightness.dark,
),
child: Scaffold(
backgroundColor: _background,
appBar: AppBar(
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
toolbarHeight: 44.dp,
centerTitle: true,
title: Text(
'账号设置',
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 16.dp,
fontWeight: FontWeight.w500,
),
),
leading: IconButton(
highlightColor: Colors.transparent,
splashColor: Colors.transparent,
padding: EdgeInsets.zero,
onPressed: controller.executeBackLogic,
icon: Image.asset(
R.assetsImagesNavBackIcon,
width: 28.dp,
height: 28.dp,
),
),
),
body: Obx(() {
final user = controller.userPrefs.preferences.value.meUserInfo;
return Column(
children: [
SizedBox(height: 16.dp),
_AccountCard(user: user, onLogout: controller.logout),
SizedBox(height: 17.dp),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: controller.openOnboarding,
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 32.dp,
vertical: 8.dp,
),
child: Text(
'注销账号',
style: TextStyle(
color: _danger,
fontSize: 12.dp,
fontWeight: FontWeight.w400,
height: 1.25,
),
),
),
),
],
);
}),
),
);
}
}
class _AccountCard extends StatelessWidget {
const _AccountCard({
required this.user,
required this.onLogout,
});
final UserInfoResponse? user;
final VoidCallback onLogout;
@override
Widget build(BuildContext context) {
return Container(
height: 110.dp,
margin: EdgeInsets.symmetric(horizontal: 15.dp),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16.dp),
),
child: Column(
children: [
SizedBox(
height: 54.dp,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 20.dp),
child: Row(
children: [
Text(
'手机号',
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 14.dp,
fontWeight: FontWeight.w400,
height: 1.25,
),
),
const Spacer(),
Text(
_formatPhone(user?.telephone),
style: TextStyle(
color: AppColors.textSecondary,
fontSize: 14.dp,
fontWeight: FontWeight.w400,
height: 1.25,
),
),
],
),
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 20.dp),
child: Divider(
height: 1.dp,
thickness: 1.dp,
color: const Color(0xFFF3F3F3),
),
),
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onLogout,
child: Center(
child: Text(
'退出登录',
style: TextStyle(
color: AppColors.primary,
fontSize: 14.dp,
fontWeight: FontWeight.w500,
height: 1.25,
),
),
),
),
),
],
),
);
}
String _formatPhone(String? phone) {
final normalized = phone?.replaceAll(' ', '').trim();
if (normalized == null || normalized.isEmpty) {
return '130 1478 9632';
}
if (normalized.length == 11) {
return '${normalized.substring(0, 3)} '
'${normalized.substring(3, 7)} '
'${normalized.substring(7)}';
}
return normalized;
}
}
... ...
import 'package:doublefeel_flutter/app/modules/watch_theme/models/watch_theme_models.dart';
import 'package:doublefeel_flutter/app/routes/app_pages.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/util/size_extensions.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:doublefeel_flutter/data/models/user/user_models.dart';
import 'package:doublefeel_flutter/r.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class MyTab extends StatelessWidget {
const MyTab({super.key});
static const _background = Color(0xFFF5F2FF);
static const _proPurple = Color(0xFF916DF5);
static const _proGold = Color(0xFFFFDF51);
@override
Widget build(BuildContext context) {
final userPrefs = Get.find<UserPreferencesStorage>();
return Container(
color: _background,
child: SafeArea(
bottom: false,
child: Obx(() {
final user = userPrefs.preferences.value.meUserInfo;
return ListView(
physics: const ClampingScrollPhysics(),
padding: EdgeInsets.fromLTRB(16.dp, 59.dp, 16.dp, 112.dp),
children: [
_ProfileHeader(
user: user,
onTap: () => Get.toNamed(Routes.ACCOUNT_SETTINGS),
),
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(
title: '账号信息',
onTap: () => Get.toNamed(Routes.ACCOUNT_SETTINGS),
),
SizedBox(height: 12.dp),
_MenuTile(title: '帮助', onTap: () {}),
],
);
}),
),
);
}
}
class _ProfileHeader extends StatelessWidget {
const _ProfileHeader({
required this.user,
required this.onTap,
});
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(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
_displayName(user),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 20.dp,
fontWeight: FontWeight.w500,
height: 1.25,
),
),
),
SizedBox(width: 8.dp),
Icon(
Icons.edit_outlined,
color: const Color(0xFFA084EF),
size: 16.dp,
),
],
),
SizedBox(height: 6.dp),
Text(
'ID:${user?.id ?? 738293}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppColors.textSecondary,
fontSize: 14.dp,
fontWeight: FontWeight.w400,
height: 1.25,
),
),
],
),
),
],
),
),
);
}
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});
final String? avatarUrl;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
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: [
GestureDetector(
onTap: () async {
var userStateService = Get.find<UserStateService>();
await userStateService.onLogout();
Get.offAllNamed(AppRoutes.initial);
},
child: Container(
padding: const EdgeInsets.all(10),
margin: const EdgeInsets.only(top: 10),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(10),
ClipOval(
child: Image(
image: imageProvider,
width: 80.dp,
height: 80.dp,
fit: BoxFit.cover,
),
),
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 24.dp,
height: 24.dp,
decoration: BoxDecoration(
color: const Color(0xFFE8E0FF),
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,
),
),
),
],
),
);
}
}
class _PremiumCard extends StatelessWidget {
const _PremiumCard();
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => Get.toNamed(Routes.PURCHASE),
child: Container(
height: 128.dp,
decoration: BoxDecoration(
color: MyTab._proPurple,
borderRadius: BorderRadius.circular(16.dp),
),
clipBehavior: Clip.antiAlias,
child: Stack(
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),
),
),
),
],
),
child: const Text('Logout'),
)),
GestureDetector(
onTap: () async {
Get.offAllNamed(AppRoutes.userOnboarding);
},
child: Container(
padding: const EdgeInsets.all(10),
margin: const EdgeInsets.only(top: 10),
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(10),
),
),
Padding(
padding: EdgeInsets.fromLTRB(20.dp, 20.dp, 20.dp, 20.dp),
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,
),
),
),
SizedBox(height: 5.dp),
Text(
'开启压力预警与健康陪伴之旅',
style: TextStyle(
color: const Color(0xFFC6B3FF),
fontSize: 12.dp,
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,
),
),
],
),
),
],
),
),
],
),
),
);
}
}
class _WatchThemeCard extends StatelessWidget {
const _WatchThemeCard({required this.onTap});
final VoidCallback onTap;
final double _itemSize = 64;
final double _overlap = 8;
@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,
),
),
child: const Text('Onboarding'),
)),
],
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 ??
'',
));
},
),
),
SizedBox(height: 10.dp),
Text(
'支持自定义创作主题哦~',
style: TextStyle(
color: AppColors.primary,
fontSize: 12.dp,
fontWeight: FontWeight.w500,
height: 1.25,
),
),
],
),
),
);
}
}
class _ThemeBubble extends StatelessWidget {
const _ThemeBubble({required this.size, required this.assetPath});
final double size;
final String assetPath;
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
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,
),
);
}
}
class _MenuTile extends StatelessWidget {
const _MenuTile({
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.dp,
padding: EdgeInsets.symmetric(horizontal: 20.dp),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16.dp),
),
child: Row(
children: [
Text(
title,
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 14.dp,
fontWeight: FontWeight.w400,
height: 1.25,
),
),
const Spacer(),
Icon(
Icons.chevron_right,
color: AppColors.textTertiary,
size: 20.dp,
),
],
),
),
);
}
... ...
import 'package:get/get.dart';
import '../controllers/watch_theme_controller.dart';
class WatchThemeBinding extends Bindings {
@override
void dependencies() {
if (!Get.isRegistered<WatchThemeController>()) {
Get.lazyPut<WatchThemeController>(() => WatchThemeController());
}
}
}
... ...
import 'package:doublefeel_flutter/app/routes/app_pages.dart';
import 'package:doublefeel_flutter/core/util/app_toast.dart';
import 'package:doublefeel_flutter/pigeon/wear_engine_api.g.dart';
import 'package:doublefeel_flutter/r.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../models/watch_theme_models.dart';
class WatchThemeController extends GetxController {
final selectedOfficialIndex = 0.obs;
final hasCustomThemes = false.obs;
final isPremium = false.obs;
final customThemeName = ''.obs;
final agreedToSubmission = true.obs;
final customStatusNames = ['状态优秀', '状态正常', '注意压力', '压力过载'].obs;
final customImagePaths = RxList<String?>.filled(4, null);
late final TextEditingController themeNameController;
@override
void onInit() {
super.onInit();
themeNameController = TextEditingController();
themeNameController.addListener(() {
customThemeName.value = themeNameController.text.trim();
});
final args = Get.arguments;
if (args is Map) {
hasCustomThemes.value = args['hasCustomThemes'] == true;
isPremium.value = args['isPremium'] == true || hasCustomThemes.value;
}
}
@override
void onClose() {
themeNameController.dispose();
super.onClose();
}
bool get canSaveCustomTheme =>
customThemeName.value.isNotEmpty &&
agreedToSubmission.value &&
customImagePaths.any((path) => path != null && path.isNotEmpty);
void executeBackLogic() {
Get.back();
}
void selectOfficialTheme(int index) {
selectedOfficialIndex.value = index;
Get.toNamed(Routes.WATCH_THEME_PREVIEW);
}
void createCustomTheme() {
Get.toNamed(Routes.WATCH_THEME_CREATE);
}
void previewCustomTheme() {
Get.toNamed(Routes.WATCH_THEME_CUSTOM_PREVIEW);
}
Future<void> pickCustomImage(int index) async {
try {
final path = await WearEngineHostApi().pickImageAndRemoveBackground();
if (path == null || path.isEmpty) {
return;
}
customImagePaths[index] = path;
} catch (error) {
AppToast.show('图片选择失败');
}
}
Future<void> showRenameStatusDialog(int index) async {
final result = await Get.dialog<String>(
WatchThemeRenameDialog(initialValue: customStatusNames[index]),
barrierDismissible: true,
);
if (result == null || result.trim().isEmpty) {
return;
}
customStatusNames[index] = result.trim();
}
Future<void> handleCreateBack() async {
if (!customThemeName.value.isNotEmpty &&
!customImagePaths.any((path) => path != null && path.isNotEmpty)) {
Get.back();
return;
}
final abandon = await Get.dialog<bool>(
const WatchThemeConfirmDialog(
title: '放弃编辑',
message: '关闭此页面后,已编辑的内容不会保留,是否放弃编辑?',
primaryText: '放弃编辑',
secondaryText: '继续编辑',
),
barrierDismissible: true,
);
if (abandon == true) {
Get.back();
}
}
void toggleAgreement() {
agreedToSubmission.toggle();
}
void saveCustomTheme() {
if (!canSaveCustomTheme) {
return;
}
hasCustomThemes.value = true;
isPremium.value = true;
previewCustomTheme();
}
Future<void> confirmDeleteCustomTheme() async {
final shouldDelete = await Get.dialog<bool>(
const WatchThemeConfirmDialog(
title: '删除主题',
message: '主题删除后无法恢复,确认删除该主题吗?',
primaryText: '删除',
secondaryText: '取消',
),
barrierDismissible: true,
);
if (shouldDelete == true) {
Get.back();
}
}
void addWatchFace() {
// Hook to NativeHostApiStubs when the native watch-face install flow is ready.
}
}
class WatchThemeRenameDialog extends StatefulWidget {
const WatchThemeRenameDialog({super.key, required this.initialValue});
final String initialValue;
@override
State<WatchThemeRenameDialog> createState() => _WatchThemeRenameDialogState();
}
class _WatchThemeRenameDialogState extends State<WatchThemeRenameDialog> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialValue);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.transparent,
insetPadding: EdgeInsets.zero,
child: Container(
width: 300,
padding: const EdgeInsets.fromLTRB(24, 24, 24, 20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'修改状态名称',
style: TextStyle(
color: Color(0xFF141414),
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
ValueListenableBuilder<TextEditingValue>(
valueListenable: _controller,
builder: (context, value, _) {
return Container(
height: 52,
width: 252,
padding: const EdgeInsets.symmetric(horizontal: 22),
decoration: BoxDecoration(
color: const Color(0xFFF3F3F3),
borderRadius: BorderRadius.circular(27),
),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
maxLength: 4,
decoration: const InputDecoration(
counterText: '',
hintText: '请输入昵称',
hintStyle: TextStyle(color: Color(0xFFB0B0B6)),
border: InputBorder.none,
isCollapsed: true,
),
style: const TextStyle(fontSize: 16),
),
),
Text(
'${value.text.characters.length}/4',
style: const TextStyle(
color: Color(0xFFD9D9D9),
fontSize: 16,
),
),
],
),
);
},
),
const SizedBox(height: 24),
ValueListenableBuilder<TextEditingValue>(
valueListenable: _controller,
builder: (context, value, _) {
final enabled = value.text.trim().isNotEmpty;
return Opacity(
opacity: enabled ? 1 : 0.4,
child: GestureDetector(
onTap: enabled ? () => Get.back(result: value.text) : null,
child: Container(
width: 220,
height: 48,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFF845EEE),
borderRadius: BorderRadius.circular(24),
),
child: const Text(
'保存',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
),
);
},
),
],
),
),
);
}
}
class WatchThemeConfirmDialog extends StatelessWidget {
const WatchThemeConfirmDialog({
super.key,
required this.title,
required this.message,
required this.primaryText,
required this.secondaryText,
});
final String title;
final String message;
final String primaryText;
final String secondaryText;
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.transparent,
insetPadding: EdgeInsets.zero,
child: Container(
width: 300,
padding: const EdgeInsets.fromLTRB(24, 24, 24, 20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: const TextStyle(
color: Color(0xFF141414),
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xFF6E6D80),
fontSize: 15,
height: 1.35,
),
),
const SizedBox(height: 20),
GestureDetector(
onTap: () => Get.back(result: true),
child: Container(
width: 220,
height: 48,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFF845EEE),
borderRadius: BorderRadius.circular(24),
),
child: Text(
primaryText,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
),
const SizedBox(height: 4),
GestureDetector(
onTap: () => Get.back(result: false),
child: SizedBox(
width: 220,
height: 48,
child: Center(
child: Text(
secondaryText,
style: const TextStyle(
color: Color(0xFF6E6D80),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
),
),
],
),
),
);
}
}
... ...
import 'package:doublefeel_flutter/r.dart';
import 'package:flutter/foundation.dart';
class WatchThemeItem {
const WatchThemeItem({
this.id,
this.isCustomTheme = false,
this.isDefaultCharacter = false,
required this.title,
required this.infoList,
});
final int? id;
final bool isDefaultCharacter;
final bool isCustomTheme;
final String title;
final List<WatchThemeItemInfo> infoList;
}
class WatchThemeItemInfo {
final String title;
final String? imgUrl;
final String? assetPath;
WatchThemeItemInfo({required this.title, this.imgUrl, this.assetPath});
}
final officialThemes = <WatchThemeItem>[
WatchThemeItem(
title: '默认主题',
isDefaultCharacter: true,
infoList: [
WatchThemeItemInfo(
title: '状态优秀',
assetPath: 'assets/images/watch_theme/official_default_green.png',
),
WatchThemeItemInfo(
title: '状态正常',
assetPath: 'assets/images/watch_theme/official_default_blue.png',
),
WatchThemeItemInfo(
title: '注意压力',
assetPath: 'assets/images/watch_theme/official_default_orange.png',
),
WatchThemeItemInfo(
title: '压力过载',
assetPath: 'assets/images/watch_theme/official_default_red.png',
),
],
),
WatchThemeItem(
title: '垂耳粉兔',
infoList: [
WatchThemeItemInfo(
title: '状态优秀',
assetPath: 'assets/images/watch_theme/official_rabbit_pink.png',
),
WatchThemeItemInfo(
title: '状态正常',
assetPath: 'assets/images/watch_theme/official_rabbit_pink.png',
),
WatchThemeItemInfo(
title: '注意压力',
assetPath: 'assets/images/watch_theme/official_rabbit_pink.png',
),
WatchThemeItemInfo(
title: '压力过载',
assetPath: 'assets/images/watch_theme/official_rabbit_pink.png',
),
],
),
WatchThemeItem(
title: '蓝象哥哥',
infoList: [
WatchThemeItemInfo(
title: '状态优秀',
assetPath: 'assets/images/watch_theme/official_elephant_blue.png',
),
WatchThemeItemInfo(
title: '状态正常',
assetPath: 'assets/images/watch_theme/official_elephant_blue.png',
),
WatchThemeItemInfo(
title: '注意压力',
assetPath: 'assets/images/watch_theme/official_elephant_blue.png',
),
WatchThemeItemInfo(
title: '压力过载',
assetPath: 'assets/images/watch_theme/official_elephant_blue.png',
),
],
),
WatchThemeItem(
title: '小狗白白',
infoList: [
WatchThemeItemInfo(
title: '状态优秀',
assetPath: 'assets/images/watch_theme/official_dog_white.png',
),
WatchThemeItemInfo(
title: '状态正常',
assetPath: 'assets/images/watch_theme/official_dog_white.png',
),
WatchThemeItemInfo(
title: '注意压力',
assetPath: 'assets/images/watch_theme/official_dog_white.png',
),
WatchThemeItemInfo(
title: '压力过载',
assetPath: 'assets/images/watch_theme/official_dog_white.png',
),
],
),
WatchThemeItem(
title: '猫猫狗狗',
infoList: [
WatchThemeItemInfo(
title: '状态优秀',
assetPath: 'assets/images/watch_theme/custom_cat_dog.png',
),
WatchThemeItemInfo(
title: '状态正常',
assetPath: 'assets/images/watch_theme/custom_cat_dog.png',
),
WatchThemeItemInfo(
title: '注意压力',
assetPath: 'assets/images/watch_theme/custom_cat_dog.png',
),
WatchThemeItemInfo(
title: '压力过载',
assetPath: 'assets/images/watch_theme/custom_cat_dog.png',
),
],
),
];
final customThemes = <WatchThemeItem>[
WatchThemeItem(
title: '猫猫狗狗-AAA',
infoList: [
WatchThemeItemInfo(
title: '状态优秀',
assetPath: 'assets/images/watch_theme/custom_cat_dog.png',
),
WatchThemeItemInfo(
title: '状态正常',
assetPath: 'assets/images/watch_theme/custom_cat_dog.png',
),
WatchThemeItemInfo(
title: '注意压力',
assetPath: 'assets/images/watch_theme/custom_cat_dog.png',
),
WatchThemeItemInfo(
title: '压力过载',
assetPath: 'assets/images/watch_theme/custom_cat_dog.png',
),
],
),
];
... ...
import 'dart:io';
import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:doublefeel_flutter/r.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import '../controllers/watch_theme_controller.dart';
import '../widgets/watch_face_preview.dart';
import '../widgets/watch_theme_colors.dart';
import '../widgets/watch_theme_nav_bar.dart';
class CreateWatchThemeView extends GetView<WatchThemeController> {
const CreateWatchThemeView({super.key});
@override
Widget build(BuildContext context) {
return AnnotatedRegion<SystemUiOverlayStyle>(
value: const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
systemNavigationBarColor: WatchThemeColors.background,
systemNavigationBarIconBrightness: Brightness.dark,
),
child: PopScope(
canPop: false,
onPopInvokedWithResult: (_, __) => controller.handleCreateBack(),
child: Scaffold(
backgroundColor: WatchThemeColors.background,
body: Stack(
children: [
const Positioned(
left: 0,
right: 0,
top: 0,
height: 300,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
WatchThemeColors.gradientTop,
WatchThemeColors.background,
],
),
),
),
),
SafeArea(
bottom: false,
child: Column(
children: [
WatchThemeNavBar(
title: '创作主题',
onBack: controller.handleCreateBack,
),
Expanded(
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
padding: EdgeInsets.only(bottom: 28.dp),
child: Column(
children: [
SizedBox(height: 12.dp),
WatchFacePreview(
faceAsset:
R.assetsImagesWatchThemeCustomFaceCreate,
),
SizedBox(height: 20.dp),
_EditorCard(controller: controller),
SizedBox(height: 26.dp),
_AgreementRow(controller: controller),
SizedBox(height: 18.dp),
_SaveButton(controller: controller),
],
),
),
),
],
),
),
],
),
),
),
);
}
}
class _EditorCard extends StatelessWidget {
const _EditorCard({required this.controller});
final WatchThemeController controller;
static const _statusColors = [
WatchThemeColors.excellent,
WatchThemeColors.normal,
WatchThemeColors.stress,
WatchThemeColors.overload,
];
@override
Widget build(BuildContext context) {
return Container(
margin: EdgeInsets.symmetric(horizontal: 16.dp),
padding: EdgeInsets.fromLTRB(20.dp, 20.dp, 20.dp, 20.dp),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16.dp),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'自定义主题',
style: TextStyle(
color: WatchThemeColors.textPrimary,
fontSize: 16.dp,
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 4.dp),
Text(
'支持上传图片、视频、Live图、Gif、最长5秒',
style: TextStyle(
color: WatchThemeColors.textSecondary,
fontSize: 12.dp,
),
),
SizedBox(height: 14.dp),
Obx(
() => GridView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
physics: const NeverScrollableScrollPhysics(),
itemCount: 4,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 23.dp,
mainAxisSpacing: 14.dp,
mainAxisExtent: 170.dp,
),
itemBuilder: (context, index) {
return _UploadTile(
index: index,
color: _statusColors[index],
label: controller.customStatusNames[index],
path: controller.customImagePaths[index],
onPick: () => controller.pickCustomImage(index),
onRename: () => controller.showRenameStatusDialog(index),
);
},
),
),
SizedBox(height: 14.dp),
Text(
'主题名称',
style: TextStyle(
color: WatchThemeColors.textPrimary,
fontSize: 16.dp,
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 14.dp),
Container(
height: 52.dp,
padding: EdgeInsets.symmetric(horizontal: 26.dp),
decoration: BoxDecoration(
color: WatchThemeColors.background,
borderRadius: BorderRadius.circular(16.dp),
),
alignment: Alignment.center,
child: TextField(
controller: controller.themeNameController,
maxLength: 10,
decoration: InputDecoration(
counterText: '',
hintText: '最多10个字符',
hintStyle: TextStyle(
color: WatchThemeColors.textTertiary,
fontSize: 14.dp,
),
border: InputBorder.none,
isCollapsed: true,
),
style: TextStyle(
color: WatchThemeColors.textPrimary,
fontSize: 14.dp,
),
),
),
],
),
);
}
}
class _UploadTile extends StatelessWidget {
const _UploadTile({
required this.index,
required this.color,
required this.label,
required this.path,
required this.onPick,
required this.onRename,
});
final int index;
final Color color;
final String label;
final String? path;
final VoidCallback onPick;
final VoidCallback onRename;
@override
Widget build(BuildContext context) {
return Column(
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onPick,
child: Container(
width: 140.dp,
height: 140.dp,
decoration: BoxDecoration(
color: WatchThemeColors.background,
borderRadius: BorderRadius.circular(18.667.dp),
),
clipBehavior: Clip.antiAlias,
child: path == null
? Icon(Icons.add, size: 34.dp, color: color)
: Image.file(File(path!), fit: BoxFit.cover),
),
),
SizedBox(height: 10.dp),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
label,
style: TextStyle(
color: WatchThemeColors.textPrimary,
fontSize: 12.dp,
),
),
SizedBox(width: 4.dp),
GestureDetector(
onTap: onRename,
child: Icon(
Icons.edit_outlined,
color: const Color(0xFFA084EF),
size: 14.dp,
),
),
],
),
],
);
}
}
class _AgreementRow extends StatelessWidget {
const _AgreementRow({required this.controller});
final WatchThemeController controller;
@override
Widget build(BuildContext context) {
return Obx(
() => GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: controller.toggleAgreement,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 16.dp,
height: 16.dp,
decoration: BoxDecoration(
color: controller.agreedToSubmission.value
? WatchThemeColors.brand
: Colors.transparent,
border: Border.all(color: WatchThemeColors.brand, width: 1.dp),
shape: BoxShape.circle,
),
child: controller.agreedToSubmission.value
? Icon(Icons.check, color: Colors.white, size: 12.dp)
: null,
),
SizedBox(width: 4.dp),
Text(
'我已阅读并同意用户投稿协议',
style: TextStyle(
color: WatchThemeColors.textSecondary,
fontSize: 12.dp,
),
),
],
),
),
);
}
}
class _SaveButton extends StatelessWidget {
const _SaveButton({required this.controller});
final WatchThemeController controller;
@override
Widget build(BuildContext context) {
return Obx(
() {
final enabled = controller.canSaveCustomTheme;
return Opacity(
opacity: enabled ? 1 : 0.4,
child: GestureDetector(
onTap: enabled ? controller.saveCustomTheme : null,
child: Container(
width: 280.dp,
height: 48.dp,
alignment: Alignment.center,
decoration: BoxDecoration(
color: WatchThemeColors.brand,
borderRadius: BorderRadius.circular(24.dp),
),
child: Text(
'保存主题',
style: TextStyle(
color: Colors.white,
fontSize: 16.dp,
fontWeight: FontWeight.w600,
),
),
),
),
);
},
);
}
}
... ...
import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:doublefeel_flutter/r.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import '../controllers/watch_theme_controller.dart';
import '../widgets/watch_face_preview.dart';
import '../widgets/watch_theme_bottom_actions.dart';
import '../widgets/watch_theme_colors.dart';
import '../widgets/watch_theme_header.dart';
import '../widgets/watch_theme_nav_bar.dart';
import '../widgets/watch_theme_section_card.dart';
class CustomWatchThemePreviewView extends GetView<WatchThemeController> {
const CustomWatchThemePreviewView({super.key});
@override
Widget build(BuildContext context) {
return AnnotatedRegion<SystemUiOverlayStyle>(
value: const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
systemNavigationBarColor: WatchThemeColors.background,
systemNavigationBarIconBrightness: Brightness.dark,
),
child: Scaffold(
backgroundColor: WatchThemeColors.background,
body: Stack(
children: [
const Positioned(
left: 0,
right: 0,
top: 0,
height: 300,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
WatchThemeColors.gradientTop,
WatchThemeColors.background,
],
),
),
),
),
SafeArea(
bottom: false,
child: Column(
children: [
WatchThemeNavBar(
title: '预览',
onBack: controller.executeBackLogic,
trailing: GestureDetector(
onTap: controller.confirmDeleteCustomTheme,
child: Text(
'删除',
style: TextStyle(
color: const Color(0xFFFC4447),
fontSize: 14.dp,
fontWeight: FontWeight.w500,
),
),
),
),
Expanded(
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
padding: EdgeInsets.only(bottom: 96.dp),
child: Obx(
() => Column(
children: [
WatchThemeHeader(
title: controller.customThemeName.value.isEmpty
? '默认主题'
: controller.customThemeName.value,
faceAsset:
R.assetsImagesWatchThemeCustomFacePreview,
),
SizedBox(height: 26.dp),
_CustomStatusPreviewCard(controller: controller),
SizedBox(height: 12.dp),
const _CustomDialPreviewCard(),
],
),
),
),
),
],
),
),
],
),
bottomNavigationBar: WatchThemeBottomActions(
onAddTap: controller.addWatchFace,
secondaryLabel: '立即使用',
onSecondaryTap: controller.addWatchFace,
secondaryDisabled: false,
),
),
);
}
}
class _CustomStatusPreviewCard extends StatelessWidget {
const _CustomStatusPreviewCard({required this.controller});
final WatchThemeController controller;
static final _assets = [
R.assetsImagesWatchThemeCustomStatusExcellent,
R.assetsImagesWatchThemeCustomStatusNormal,
R.assetsImagesWatchThemeCustomStatusStress,
R.assetsImagesWatchThemeCustomStatusOverload,
];
static const _colors = [
WatchThemeColors.excellent,
WatchThemeColors.normal,
WatchThemeColors.stress,
WatchThemeColors.overload,
];
@override
Widget build(BuildContext context) {
return WatchThemeSectionCard(
padding: EdgeInsets.fromLTRB(20.dp, 20.dp, 20.dp, 17.dp),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const WatchThemeSectionTitle('状态预览'),
SizedBox(height: 24.dp),
Obx(
() => Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
for (var i = 0; i < 4; i++)
Column(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(16.dp),
child: Image.asset(
_assets[i],
width: 60.dp,
height: 60.dp,
fit: BoxFit.cover,
),
),
SizedBox(height: 4.dp),
Text(
controller.customStatusNames[i],
style: TextStyle(
color: _colors[i],
fontSize: 12.dp,
),
),
],
),
],
),
),
],
),
);
}
}
class _CustomDialPreviewCard extends StatelessWidget {
const _CustomDialPreviewCard();
@override
Widget build(BuildContext context) {
return WatchThemeSectionCard(
padding: EdgeInsets.fromLTRB(20.dp, 20.dp, 0, 27.dp),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const WatchThemeSectionTitle('表盘预览'),
SizedBox(height: 16.dp),
Row(
children: [
WatchFacePreview(
width: 114,
height: 136,
faceAsset: R.assetsImagesWatchThemeCustomPreviewAlt,
),
SizedBox(width: 18.dp),
WatchFacePreview(
width: 114,
height: 136,
faceAsset: R.assetsImagesWatchThemeCustomFacePreview,
),
],
),
],
),
);
}
}
... ...
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import '../controllers/watch_theme_controller.dart';
import '../widgets/dial_preview_card.dart';
import '../widgets/status_preview_card.dart';
import '../widgets/watch_theme_bottom_actions.dart';
import '../widgets/watch_theme_colors.dart';
import '../widgets/watch_theme_header.dart';
import '../widgets/watch_theme_nav_bar.dart';
class WatchThemePreviewView extends GetView<WatchThemeController> {
const WatchThemePreviewView({super.key});
@override
Widget build(BuildContext context) {
return AnnotatedRegion<SystemUiOverlayStyle>(
value: const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
systemNavigationBarColor: WatchThemeColors.background,
systemNavigationBarIconBrightness: Brightness.dark,
),
child: Scaffold(
backgroundColor: WatchThemeColors.background,
body: Stack(
children: [
const Positioned(
left: 0,
right: 0,
top: 0,
height: 300,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
WatchThemeColors.gradientTop,
WatchThemeColors.background,
],
),
),
),
),
SafeArea(
bottom: false,
child: Column(
children: [
WatchThemeNavBar(
title: '预览',
onBack: controller.executeBackLogic,
),
Expanded(
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.only(bottom: 96),
child: Column(
children: const [
WatchThemeHeader(),
SizedBox(height: 26),
StatusPreviewCard(),
SizedBox(height: 12),
DialPreviewCard(),
],
),
),
),
],
),
),
],
),
bottomNavigationBar: WatchThemeBottomActions(
onAddTap: controller.addWatchFace,
),
),
);
}
}
... ...
import 'package:doublefeel_flutter/app/modules/watch_theme/models/watch_theme_models.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import '../controllers/watch_theme_controller.dart';
import '../widgets/custom_theme_card.dart';
import '../widgets/official_theme_grid.dart';
import '../widgets/watch_theme_colors.dart';
import '../widgets/watch_theme_header.dart';
import '../widgets/watch_theme_nav_bar.dart';
class WatchThemeView extends GetView<WatchThemeController> {
const WatchThemeView({super.key});
@override
Widget build(BuildContext context) {
return AnnotatedRegion<SystemUiOverlayStyle>(
value: const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
systemNavigationBarColor: WatchThemeColors.background,
systemNavigationBarIconBrightness: Brightness.dark,
),
child: Scaffold(
backgroundColor: WatchThemeColors.background,
body: Stack(
children: [
const Positioned(
left: 0,
right: 0,
top: 0,
height: 300,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
WatchThemeColors.gradientTop,
WatchThemeColors.background,
],
),
),
),
),
SafeArea(
bottom: false,
child: Column(
children: [
WatchThemeNavBar(
title: 'Watch主题',
onBack: controller.executeBackLogic,
),
Expanded(
child: Obx(
() => SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.only(bottom: 24),
child: Column(
children: [
const WatchThemeHeader(),
const SizedBox(height: 26),
OfficialThemeGrid(
themes: officialThemes,
selectedIndex:
controller.selectedOfficialIndex.value,
onThemeTap: controller.selectOfficialTheme,
),
const SizedBox(height: 12),
CustomThemeCard(
isPremium: controller.isPremium.value,
hasCustomThemes: controller.hasCustomThemes.value,
customThemes: customThemes,
onCreateTap: controller.createCustomTheme,
onThemeTap: (_) =>
controller.previewCustomTheme(),
),
],
),
),
),
),
],
),
),
],
),
),
);
}
}
... ...
import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:flutter/material.dart';
import '../models/watch_theme_models.dart';
import 'watch_theme_character_avatar.dart';
import 'watch_theme_colors.dart';
import 'watch_theme_pro_badge.dart';
import 'watch_theme_section_card.dart';
class CustomThemeCard extends StatelessWidget {
const CustomThemeCard({
super.key,
required this.isPremium,
required this.hasCustomThemes,
required this.customThemes,
required this.onCreateTap,
this.onThemeTap,
});
final bool isPremium;
final bool hasCustomThemes;
final List<WatchThemeItem> customThemes;
final VoidCallback onCreateTap;
final ValueChanged<WatchThemeItem>? onThemeTap;
@override
Widget build(BuildContext context) {
return WatchThemeSectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
WatchThemeSectionTitle(
'自定义主题',
trailing: isPremium ? null : const WatchThemeProBadge(),
),
SizedBox(height: 4.dp),
Text(
'用创意记录每一次情绪波动,打造懂你的专属表盘吧~',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: WatchThemeColors.textSecondary,
fontSize: 12.dp,
fontWeight: FontWeight.w400,
height: 1.25,
),
),
SizedBox(height: 14.dp),
_CreateThemeButton(onTap: onCreateTap),
if (hasCustomThemes) ...[
SizedBox(height: 12.dp),
Row(
children: [
for (var index = 0; index < customThemes.length; index++) ...[
_CustomThemeItem(
item: customThemes[index],
onTap: onThemeTap == null
? null
: () => onThemeTap!(customThemes[index]),
),
if (index != customThemes.length - 1) SizedBox(width: 20.dp),
],
],
),
],
],
),
);
}
}
class _CreateThemeButton extends StatelessWidget {
const _CreateThemeButton({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
height: 48.dp,
padding: EdgeInsets.symmetric(horizontal: 20.dp),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.dp),
gradient: const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Color(0xFFFFFFD0),
Color(0xFFFFD1DB),
Color(0xFFF0CDFF),
Color(0xFFCFE8FF),
],
stops: [0, 0.4, 0.7, 1],
),
),
child: Row(
children: [
Icon(
Icons.palette_outlined,
color: WatchThemeColors.overload,
size: 28.dp,
),
SizedBox(width: 8.dp),
Text(
'创作主题',
style: TextStyle(
color: WatchThemeColors.textPrimary,
fontSize: 14.dp,
fontWeight: FontWeight.w500,
height: 1.25,
),
),
const Spacer(),
Icon(
Icons.chevron_right,
color: WatchThemeColors.textSecondary,
size: 20.dp,
),
],
),
),
);
}
}
class _CustomThemeItem extends StatelessWidget {
const _CustomThemeItem({required this.item, this.onTap});
final WatchThemeItem item;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: SizedBox(
width: 88.dp,
child: Column(
children: [
WatchThemeCharacterAvatar(
size: 88,
assetPath: item.infoList.firstOrNull?.assetPath,
empty: item.infoList.firstOrNull?.assetPath == null,
),
SizedBox(height: 8.dp),
Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: WatchThemeColors.textPrimary,
fontSize: 12.dp,
fontWeight: FontWeight.w400,
height: 1.25,
),
),
],
),
),
);
}
}
... ...
import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:doublefeel_flutter/r.dart';
import 'package:flutter/material.dart';
import 'watch_face_preview.dart';
import 'watch_theme_section_card.dart';
class DialPreviewCard extends StatelessWidget {
const DialPreviewCard({super.key});
@override
Widget build(BuildContext context) {
return WatchThemeSectionCard(
padding: EdgeInsets.fromLTRB(20.dp, 20.dp, 0, 27.dp),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const WatchThemeSectionTitle('表盘预览'),
SizedBox(height: 16.dp),
SizedBox(
height: 136.dp,
child: ListView.separated(
padding: EdgeInsets.zero,
scrollDirection: Axis.horizontal,
physics: const ClampingScrollPhysics(),
itemBuilder: (context, index) {
return WatchFacePreview(
width: 114,
height: 136,
faceAsset: index == 0
? R.assetsImagesWatchThemeFacePreviewAlt
: R.assetsImagesWatchThemeFaceDefault,
);
},
separatorBuilder: (context, index) => SizedBox(width: 18.dp),
itemCount: 2,
),
),
],
),
);
}
}
... ...
import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:flutter/material.dart';
import '../models/watch_theme_models.dart';
import 'watch_theme_character_avatar.dart';
import 'watch_theme_colors.dart';
import 'watch_theme_section_card.dart';
class OfficialThemeGrid extends StatelessWidget {
const OfficialThemeGrid({
super.key,
required this.themes,
required this.selectedIndex,
required this.onThemeTap,
});
final List<WatchThemeItem> themes;
final int selectedIndex;
final ValueChanged<int> onThemeTap;
@override
Widget build(BuildContext context) {
return WatchThemeSectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const WatchThemeSectionTitle('官方主题'),
SizedBox(height: 22.dp),
GridView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
physics: const NeverScrollableScrollPhysics(),
itemCount: themes.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 21.dp,
crossAxisSpacing: 19.dp,
mainAxisExtent: 106.dp,
),
itemBuilder: (context, index) {
final theme = themes[index];
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onThemeTap(index),
child: Column(
children: [
WatchThemeCharacterAvatar(
size: 88,
selected: index == selectedIndex,
useDefaultCharacter: theme.isDefaultCharacter,
assetPath: theme.infoList.firstOrNull?.assetPath,
),
SizedBox(height: 8.dp),
Text(
theme.title,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: WatchThemeColors.textPrimary,
fontSize: 12.dp,
fontWeight: FontWeight.w400,
height: 1.25,
),
),
],
),
);
},
),
],
),
);
}
}
... ...
import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:flutter/material.dart';
import 'watch_theme_character_avatar.dart';
import 'watch_theme_colors.dart';
import 'watch_theme_section_card.dart';
class StatusPreviewCard extends StatelessWidget {
const StatusPreviewCard({super.key});
static const _items = [
_StatusPreviewItem('状态优秀', WatchThemeColors.excellent,
icon: 'assets/images/watch_theme/official_default_green.png'),
_StatusPreviewItem('状态正常', WatchThemeColors.normal,
icon: 'assets/images/watch_theme/official_default_blue.png'),
_StatusPreviewItem('注意压力', WatchThemeColors.stress,
icon: 'assets/images/watch_theme/official_default_orange.png'),
_StatusPreviewItem('压力过载', WatchThemeColors.overload,
icon: 'assets/images/watch_theme/official_default_red.png'),
];
@override
Widget build(BuildContext context) {
return WatchThemeSectionCard(
padding: EdgeInsets.fromLTRB(20.dp, 20.dp, 20.dp, 17.dp),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const WatchThemeSectionTitle('状态预览'),
SizedBox(height: 24.dp),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
for (final item in _items)
Column(
children: [
WatchThemeCharacterAvatar(
size: 60,
useDefaultCharacter: true,
backgroundColor: item.color,
assetPath: item.icon,
),
SizedBox(height: 4.dp),
Text(
item.title,
style: TextStyle(
color: item.color,
fontSize: 12.dp,
fontWeight: FontWeight.w400,
height: 1.25,
),
),
],
),
],
),
],
),
);
}
}
class _StatusPreviewItem {
const _StatusPreviewItem(this.title, this.color, {required this.icon});
final String title;
final Color color;
final String icon;
}
... ...
import 'dart:io';
import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:doublefeel_flutter/r.dart';
import 'package:flutter/material.dart';
import 'watch_theme_colors.dart';
class WatchFacePreview extends StatelessWidget {
const WatchFacePreview({
super.key,
this.width = 134,
this.height = 160,
this.faceAsset,
this.faceFilePath,
});
final double width;
final double height;
final String? faceAsset;
final String? faceFilePath;
@override
Widget build(BuildContext context) {
final w = width.dp;
final h = height.dp;
final inset = 6.dp;
final knobWidth = (width * 0.067).dp;
final knobHeight = (height * 0.156).dp;
return SizedBox(
width: w + knobWidth,
height: h,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 0,
top: 0,
child: Container(
width: w,
height: h,
decoration: BoxDecoration(
color: WatchThemeColors.watchShell,
borderRadius: BorderRadius.circular((width * 0.239).dp),
),
),
),
Positioned(
left: inset,
top: inset,
child: Container(
width: w - inset * 2,
height: h - inset * 2,
decoration: BoxDecoration(
color: WatchThemeColors.watchScreen,
borderRadius: BorderRadius.circular((width * 0.209).dp),
),
clipBehavior: Clip.antiAlias,
child: _buildFaceImage(),
),
),
Positioned(
left: w - 4.dp,
top: (height * 0.2375).dp,
child: Container(
width: knobWidth,
height: knobHeight,
decoration: BoxDecoration(
color: WatchThemeColors.watchShell,
borderRadius: BorderRadius.horizontal(
right: Radius.circular(5.dp),
),
),
),
),
Positioned(
left: w - 1.dp,
top: (height * 0.5125).dp,
child: Container(
width: 3.dp,
height: (height * 0.256).dp,
decoration: BoxDecoration(
color: WatchThemeColors.watchShell,
borderRadius: BorderRadius.horizontal(
right: Radius.circular(2.dp),
),
),
),
),
],
),
);
}
Widget _buildFaceImage() {
if (faceFilePath != null && faceFilePath!.isNotEmpty) {
return Image.file(File(faceFilePath!), fit: BoxFit.cover);
}
return Image.asset(
faceAsset ?? R.assetsImagesWatchThemeFaceDefault,
fit: BoxFit.cover,
);
}
}
... ...
import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:flutter/material.dart';
import 'watch_theme_colors.dart';
class WatchThemeBottomActions extends StatelessWidget {
const WatchThemeBottomActions({
super.key,
required this.onAddTap,
this.secondaryLabel = '正在使用',
this.onSecondaryTap,
this.secondaryDisabled = true,
});
final VoidCallback onAddTap;
final String secondaryLabel;
final VoidCallback? onSecondaryTap;
final bool secondaryDisabled;
@override
Widget build(BuildContext context) {
return SafeArea(
top: false,
child: Container(
padding: EdgeInsets.fromLTRB(21.dp, 8.dp, 21.dp, 7.dp),
color: WatchThemeColors.background,
child: Row(
children: [
Expanded(
child: _ActionButton(
label: '添加表盘',
color: WatchThemeColors.brand,
textColor: Colors.white,
onTap: onAddTap,
),
),
SizedBox(width: 12.dp),
Expanded(
child: _ActionButton(
label: secondaryLabel,
color: secondaryDisabled
? WatchThemeColors.brand.withValues(alpha: 0.4)
: WatchThemeColors.brand,
textColor:
secondaryDisabled ? WatchThemeColors.brand : Colors.white,
onTap: onSecondaryTap,
),
),
],
),
),
);
}
}
class _ActionButton extends StatelessWidget {
const _ActionButton({
required this.label,
required this.color,
required this.textColor,
required this.onTap,
});
final String label;
final Color color;
final Color textColor;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
height: 48.dp,
alignment: Alignment.center,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(24.dp),
),
child: Text(
label,
style: TextStyle(
color: textColor,
fontSize: 16.dp,
fontWeight: FontWeight.w600,
height: 1.25,
),
),
),
);
}
}
... ...
import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:flutter/material.dart';
import 'watch_theme_colors.dart';
class WatchThemeCharacterAvatar extends StatelessWidget {
const WatchThemeCharacterAvatar({
super.key,
required this.size,
this.assetPath,
this.selected = false,
this.empty = false,
this.useDefaultCharacter = false,
this.backgroundColor = Colors.transparent,
});
final double size;
final String? assetPath;
final bool selected;
final bool empty;
final bool useDefaultCharacter;
final Color backgroundColor;
@override
Widget build(BuildContext context) {
final avatarSize = size.dp;
return SizedBox(
width: avatarSize,
height: avatarSize,
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
width: avatarSize,
height: avatarSize,
decoration: BoxDecoration(
color: backgroundColor,
shape: BoxShape.circle,
border: selected
? Border.all(color: WatchThemeColors.brand, width: 2.dp)
: null,
),
alignment: Alignment.center,
child: empty
? const SizedBox.shrink()
: useDefaultCharacter
? assetPath == null
? CustomPaint(
size: Size(avatarSize * 0.68, avatarSize * 0.68),
painter: const _DefaultCharacterPainter(),
)
: Image.asset(
assetPath!,
width: avatarSize * 0.82,
height: avatarSize * 0.82,
fit: BoxFit.contain,
)
: Image.asset(
assetPath!,
width: avatarSize * 0.82,
height: avatarSize * 0.82,
fit: BoxFit.contain,
),
),
if (selected)
Positioned(
right: -1.dp,
bottom: 9.dp,
child: Container(
width: 16.dp,
height: 16.dp,
decoration: const BoxDecoration(
color: WatchThemeColors.brand,
shape: BoxShape.circle,
),
child: Icon(
Icons.check,
color: Colors.white,
size: 12.dp,
),
),
),
],
),
);
}
}
class _DefaultCharacterPainter extends CustomPainter {
const _DefaultCharacterPainter();
@override
void paint(Canvas canvas, Size size) {
final body = Paint()..color = const Color(0xFF3DD79E);
final blush = Paint()..color = const Color(0xFFFF9A6E);
final dark = Paint()
..color = const Color(0xFF153D2A)
..strokeWidth = size.width * 0.045
..strokeCap = StrokeCap.round
..style = PaintingStyle.stroke;
final path = Path()
..moveTo(size.width * 0.32, size.height * 0.08)
..cubicTo(size.width * 0.74, size.height * 0.02, size.width * 0.92,
size.height * 0.35, size.width * 0.82, size.height * 0.76)
..cubicTo(size.width * 0.62, size.height * 1.03, size.width * 0.12,
size.height * 0.92, size.width * 0.10, size.height * 0.52)
..cubicTo(size.width * 0.08, size.height * 0.28, size.width * 0.16,
size.height * 0.15, size.width * 0.32, size.height * 0.08)
..close();
canvas.drawPath(path, body);
canvas.drawCircle(
Offset(size.width * 0.54, size.height * 0.55),
size.width * 0.09,
blush,
);
canvas.drawLine(
Offset(size.width * 0.28, size.height * 0.38),
Offset(size.width * 0.38, size.height * 0.36),
dark,
);
canvas.drawLine(
Offset(size.width * 0.59, size.height * 0.34),
Offset(size.width * 0.68, size.height * 0.31),
dark,
);
canvas.drawArc(
Rect.fromCenter(
center: Offset(size.width * 0.50, size.height * 0.55),
width: size.width * 0.22,
height: size.height * 0.14,
),
0.2,
2.2,
false,
dark,
);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
... ...
import 'package:flutter/material.dart';
class WatchThemeColors {
WatchThemeColors._();
static const background = Color(0xFFF5F2FF);
static const gradientTop = Color(0xFFC5B0FF);
static const textPrimary = Color(0xFF0F0F11);
static const textSecondary = Color(0xFF78787D);
static const textTertiary = Color(0xFFB0B0B6);
static const brand = Color(0xFF845EEE);
static const card = Colors.white;
static const avatarBackground = Color(0xFFF4F1FE);
static const watchShell = Color(0xFF9388B3);
static const watchScreen = Color(0xFF0F0F11);
static const pro = Color(0xFFFFDF51);
static const excellent = Color(0xFF3BD49D);
static const normal = Color(0xFF7B9BFB);
static const stress = Color(0xFFFF9A6E);
static const overload = Color(0xFFFF5279);
}
... ...
import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:flutter/material.dart';
import 'watch_face_preview.dart';
import 'watch_theme_colors.dart';
class WatchThemeHeader extends StatelessWidget {
const WatchThemeHeader({
super.key,
this.title = '默认主题',
this.faceAsset,
this.faceFilePath,
});
final String title;
final String? faceAsset;
final String? faceFilePath;
@override
Widget build(BuildContext context) {
return Column(
children: [
SizedBox(height: 12.dp),
Center(
child: WatchFacePreview(
faceAsset: faceAsset,
faceFilePath: faceFilePath,
),
),
SizedBox(height: 12.dp),
Text(
title,
style: TextStyle(
color: WatchThemeColors.textPrimary,
fontSize: 20.dp,
fontWeight: FontWeight.w600,
height: 1.25,
),
),
],
);
}
}
... ...
import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:doublefeel_flutter/r.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'watch_theme_colors.dart';
class WatchThemeNavBar extends StatelessWidget {
const WatchThemeNavBar({
super.key,
required this.title,
required this.onBack,
this.trailing,
});
final String title;
final VoidCallback onBack;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 44.dp,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
highlightColor: Colors.transparent,
splashColor: Colors.transparent,
padding: EdgeInsets.zero,
onPressed: onBack,
icon: Image.asset(
R.assetsImagesNavBackIcon,
width: 28.dp,
height: 28.dp,
),
),
Text(
title,
style: TextStyle(
color: WatchThemeColors.textPrimary,
fontSize: 16.dp,
fontWeight: FontWeight.w600,
height: 1.25,
),
),
trailing == null
? SizedBox(
width: 8.dp,
)
: Center(child: trailing)
],
).paddingOnly(left: 8.dp, right: 8.dp),
);
}
}
... ...
import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:flutter/material.dart';
import 'watch_theme_colors.dart';
class WatchThemeProBadge extends StatelessWidget {
const WatchThemeProBadge({super.key});
@override
Widget build(BuildContext context) {
return Container(
height: 16.dp,
padding: EdgeInsets.only(left: 5.dp, right: 6.dp),
decoration: BoxDecoration(
color: WatchThemeColors.pro,
borderRadius: BorderRadius.circular(26.dp),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.workspace_premium,
color: WatchThemeColors.textPrimary,
size: 10.dp,
),
SizedBox(width: 1.dp),
Text(
'PRO',
style: TextStyle(
color: WatchThemeColors.textPrimary,
fontSize: 10.dp,
fontWeight: FontWeight.w600,
height: 1.1,
),
),
],
),
);
}
}
... ...
import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:flutter/material.dart';
import 'watch_theme_colors.dart';
class WatchThemeSectionCard extends StatelessWidget {
const WatchThemeSectionCard({
super.key,
required this.child,
this.padding,
});
final Widget child;
final EdgeInsetsGeometry? padding;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
margin: EdgeInsets.symmetric(horizontal: 16.dp),
padding: padding ?? EdgeInsets.all(20.dp),
decoration: BoxDecoration(
color: WatchThemeColors.card,
borderRadius: BorderRadius.circular(16.dp),
),
child: child,
);
}
}
class WatchThemeSectionTitle extends StatelessWidget {
const WatchThemeSectionTitle(this.title, {super.key, this.trailing});
final String title;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: TextStyle(
color: WatchThemeColors.textPrimary,
fontSize: 16.dp,
fontWeight: FontWeight.w500,
height: 1.25,
),
),
if (trailing != null) ...[
SizedBox(width: 4.dp),
trailing!,
],
],
);
}
}
... ...
... ... @@ -24,6 +24,13 @@ import '../modules/splash/bindings/splash_binding.dart';
import '../modules/splash/views/splash_page.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';
import '../modules/watch_theme/views/custom_watch_theme_preview_view.dart';
import '../modules/watch_theme/views/create_watch_theme_view.dart';
import '../modules/watch_theme/views/watch_theme_preview_view.dart';
import '../modules/watch_theme/views/watch_theme_view.dart';
import '../modules/account_settings/bindings/account_settings_binding.dart';
import '../modules/account_settings/views/account_settings_view.dart';
import '../modules/webview/bindings/webview_binding.dart';
import '../modules/webview/views/webview_page.dart';
... ... @@ -116,5 +123,30 @@ abstract final class AppPages {
page: () => const PrivacySettingsView(),
binding: PrivacySettingsBinding(),
),
GetPage(
name: Routes.WATCH_THEME,
page: () => const WatchThemeView(),
binding: WatchThemeBinding(),
),
GetPage(
name: Routes.WATCH_THEME_PREVIEW,
page: () => const WatchThemePreviewView(),
binding: WatchThemeBinding(),
),
GetPage(
name: Routes.WATCH_THEME_CREATE,
page: () => const CreateWatchThemeView(),
binding: WatchThemeBinding(),
),
GetPage(
name: Routes.WATCH_THEME_CUSTOM_PREVIEW,
page: () => const CustomWatchThemePreviewView(),
binding: WatchThemeBinding(),
),
GetPage(
name: Routes.ACCOUNT_SETTINGS,
page: () => const AccountSettingsView(),
binding: AccountSettingsBinding(),
),
];
}
... ...
... ... @@ -11,6 +11,11 @@ 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 WATCH_THEME = _Paths.WATCH_THEME;
static const WATCH_THEME_PREVIEW = _Paths.WATCH_THEME_PREVIEW;
static const WATCH_THEME_CREATE = _Paths.WATCH_THEME_CREATE;
static const WATCH_THEME_CUSTOM_PREVIEW = _Paths.WATCH_THEME_CUSTOM_PREVIEW;
static const ACCOUNT_SETTINGS = _Paths.ACCOUNT_SETTINGS;
}
abstract class _Paths {
... ... @@ -23,4 +28,9 @@ abstract class _Paths {
static const SELECT_FRIEND = '/select-friend';
static const PREMIUM_ACTIVATED = '/premium-activated';
static const PRIVACY_SETTINGS = '/privacy-settings';
static const WATCH_THEME = '/watch-theme';
static const WATCH_THEME_PREVIEW = '/watch-theme/preview';
static const WATCH_THEME_CREATE = '/watch-theme/create';
static const WATCH_THEME_CUSTOM_PREVIEW = '/watch-theme/custom-preview';
static const ACCOUNT_SETTINGS = '/account-settings';
}
... ...
... ... @@ -15,21 +15,22 @@ PlatformException _createConnectionError(String channelName) {
message: 'Unable to establish connection on channel: "$channelName".',
);
}
bool _deepEquals(Object? a, Object? b) {
if (a is List && b is List) {
return a.length == b.length &&
a.indexed
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
}
if (a is Map && b is Map) {
return a.length == b.length && a.entries.every((MapEntry<Object?, Object?> entry) =>
(b as Map<Object?, Object?>).containsKey(entry.key) &&
_deepEquals(entry.value, b[entry.key]));
return a.length == b.length &&
a.entries.every((MapEntry<Object?, Object?> entry) =>
(b as Map<Object?, Object?>).containsKey(entry.key) &&
_deepEquals(entry.value, b[entry.key]));
}
return a == b;
}
class WearDeviceInfo {
WearDeviceInfo({
this.deviceId,
... ... @@ -52,7 +53,8 @@ class WearDeviceInfo {
}
Object encode() {
return _toList(); }
return _toList();
}
static WearDeviceInfo decode(Object result) {
result as List<Object?>;
... ... @@ -77,11 +79,9 @@ class WearDeviceInfo {
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => Object.hashAll(_toList())
;
int get hashCode => Object.hashAll(_toList());
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
... ... @@ -89,7 +89,7 @@ class _PigeonCodec extends StandardMessageCodec {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else if (value is WearDeviceInfo) {
} else if (value is WearDeviceInfo) {
buffer.putUint8(129);
writeValue(buffer, value.encode());
} else {
... ... @@ -100,7 +100,7 @@ class _PigeonCodec extends StandardMessageCodec {
@override
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
case 129:
case 129:
return WearDeviceInfo.decode(readValue(buffer)!);
default:
return super.readValueOfType(type, buffer);
... ... @@ -112,9 +112,11 @@ class WearEngineHostApi {
/// Constructor for [WearEngineHostApi]. 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.
WearEngineHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
WearEngineHostApi(
{BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
pigeonVar_messageChannelSuffix =
messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
... ... @@ -122,8 +124,10 @@ class WearEngineHostApi {
final String pigeonVar_messageChannelSuffix;
Future<bool> hasAvailableDevices() async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.WearEngineHostApi.hasAvailableDevices$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
final String pigeonVar_channelName =
'dev.flutter.pigeon.doublefeel_flutter.WearEngineHostApi.hasAvailableDevices$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel =
BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
... ... @@ -150,8 +154,10 @@ class WearEngineHostApi {
}
Future<WearDeviceInfo?> checkConnectedDevice() async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.WearEngineHostApi.checkConnectedDevice$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
final String pigeonVar_channelName =
'dev.flutter.pigeon.doublefeel_flutter.WearEngineHostApi.checkConnectedDevice$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel =
BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
... ... @@ -173,8 +179,10 @@ class WearEngineHostApi {
}
Future<bool> registerMessageReceiver() async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.WearEngineHostApi.registerMessageReceiver$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
final String pigeonVar_channelName =
'dev.flutter.pigeon.doublefeel_flutter.WearEngineHostApi.registerMessageReceiver$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel =
BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
... ... @@ -201,13 +209,16 @@ class WearEngineHostApi {
}
Future<bool> sendTextMessage(String message) async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.WearEngineHostApi.sendTextMessage$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
final String pigeonVar_channelName =
'dev.flutter.pigeon.doublefeel_flutter.WearEngineHostApi.sendTextMessage$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel =
BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[message]);
final Future<Object?> pigeonVar_sendFuture =
pigeonVar_channel.send(<Object?>[message]);
final List<Object?>? pigeonVar_replyList =
await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
... ... @@ -229,13 +240,16 @@ class WearEngineHostApi {
}
Future<bool> sendWatchSyncPayload(String jsonPayload) async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.WearEngineHostApi.sendWatchSyncPayload$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
final String pigeonVar_channelName =
'dev.flutter.pigeon.doublefeel_flutter.WearEngineHostApi.sendWatchSyncPayload$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel =
BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[jsonPayload]);
final Future<Object?> pigeonVar_sendFuture =
pigeonVar_channel.send(<Object?>[jsonPayload]);
final List<Object?>? pigeonVar_replyList =
await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
... ... @@ -255,4 +269,29 @@ class WearEngineHostApi {
return (pigeonVar_replyList[0] as bool?)!;
}
}
Future<String?> pickImageAndRemoveBackground() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.doublefeel_flutter.WearEngineHostApi.pickImageAndRemoveBackground$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 {
return (pigeonVar_replyList[0] as String?);
}
}
}
... ...
class R {
static final String assetsImagesNavBackIcon = 'assets/images/common/ic_nav_back.webp';
static final String assetsImagesHealthGoodArrow = 'assets/images/common/ic_health_good_arrow.webp';
static final String assetsImagesHealthBadArrow = 'assets/images/common/ic_health_bad_arrow.webp';
static final String assetsImagesHealthTrendUp = 'assets/images/common/ic_health_trend_up.webp';
static final String assetsImagesHealthTrendDown = 'assets/images/common/ic_health_trend_down.webp';
static final String assetsImagesHealthFriendSleepQuality = 'assets/images/common/ic_health_friend_sleep_quality.webp';
static final String assetsImagesHealthFriendSteps = 'assets/images/common/ic_health_friend_steps.webp';
static final String assetsImagesSettings = 'assets/images/common/ic_settings.png';
static final String assetsImagesCopyPurple = 'assets/images/common/ic_copy_purple.webp';
static final String assetsImagesNavBackIcon =
'assets/images/common/ic_nav_back.webp';
static final String assetsImagesHealthGoodArrow =
'assets/images/common/ic_health_good_arrow.webp';
static final String assetsImagesHealthBadArrow =
'assets/images/common/ic_health_bad_arrow.webp';
static final String assetsImagesHealthTrendUp =
'assets/images/common/ic_health_trend_up.webp';
static final String assetsImagesHealthTrendDown =
'assets/images/common/ic_health_trend_down.webp';
static final String assetsImagesHealthFriendSleepQuality =
'assets/images/common/ic_health_friend_sleep_quality.webp';
static final String assetsImagesHealthFriendSteps =
'assets/images/common/ic_health_friend_steps.webp';
static final String assetsImagesSettings =
'assets/images/common/ic_settings.png';
static final String assetsImagesCopyPurple =
'assets/images/common/ic_copy_purple.webp';
static final String assetsImagesProIcon = 'assets/images/common/ic_pro.webp';
// friends
static final String assetsImagesFriendsFriendAddMeIllustration = 'assets/images/friends/ic_friend_add_me_illustration.webp';
static final String assetsImagesFriendsFriendAddOtherIllustration = 'assets/images/friends/ic_friend_add_other_illustration.webp';
static final String assetsImagesFriendsFriendAddSubtractBg = 'assets/images/friends/bg_friend_add_subtract.webp';
static final String assetsImagesFriendsFriendAddVectorIcon = 'assets/images/friends/ic_friend_add_vector.webp';
static final String assetsImagesFriendsFriendAddMeIllustration =
'assets/images/friends/ic_friend_add_me_illustration.webp';
static final String assetsImagesFriendsFriendAddOtherIllustration =
'assets/images/friends/ic_friend_add_other_illustration.webp';
static final String assetsImagesFriendsFriendAddSubtractBg =
'assets/images/friends/bg_friend_add_subtract.webp';
static final String assetsImagesFriendsFriendAddVectorIcon =
'assets/images/friends/ic_friend_add_vector.webp';
// my
static final String assetsImagesMyAvatarDefault =
'assets/images/my/avatar_default.png';
// watch theme
static final String assetsImagesWatchThemeFaceDefault =
'assets/images/watch_theme/watch_face_default.png';
static final String assetsImagesWatchThemeFacePreviewAlt =
'assets/images/watch_theme/watch_face_preview_alt.png';
static final String assetsImagesWatchThemeCustomFaceCreate =
'assets/images/watch_theme/custom_watch_face_create.png';
static final String assetsImagesWatchThemeCustomFacePreview =
'assets/images/watch_theme/custom_watch_face_preview.png';
static final String assetsImagesWatchThemeCustomPreviewAlt =
'assets/images/watch_theme/custom_preview_alt.png';
static final String assetsImagesWatchThemeCustomStatusExcellent =
'assets/images/watch_theme/custom_status_excellent.png';
static final String assetsImagesWatchThemeCustomStatusNormal =
'assets/images/watch_theme/custom_status_normal.png';
static final String assetsImagesWatchThemeCustomStatusStress =
'assets/images/watch_theme/custom_status_stress.png';
static final String assetsImagesWatchThemeCustomStatusOverload =
'assets/images/watch_theme/custom_status_overload.png';
static final String assetsImagesWatchThemeOfficialDogWhite =
'assets/images/watch_theme/official_dog_white.png';
static final String assetsImagesWatchThemeOfficialRabbitPink =
'assets/images/watch_theme/official_rabbit_pink.png';
static final String assetsImagesWatchThemeOfficialCatOrange =
'assets/images/watch_theme/official_cat_orange.png';
static final String assetsImagesWatchThemeOfficialElephantBlue =
'assets/images/watch_theme/official_elephant_blue.png';
static final String assetsImagesWatchThemeCustomCatDog =
'assets/images/watch_theme/custom_cat_dog.png';
}
... ...
... ... @@ -39,4 +39,8 @@ abstract class WearEngineHostApi {
bool sendTextMessage(String message);
bool sendWatchSyncPayload(String jsonPayload);
/// Opens the system photo picker and returns a local PNG file path after
/// removing the image background on the host platform.
String? pickImageAndRemoveBackground();
}
... ...
... ... @@ -69,3 +69,5 @@ flutter:
- assets/images/tabbar/
- assets/images/today/
- assets/images/friends/
- assets/images/my/
- assets/images/watch_theme/
... ...