打造统一的深色主题视觉体验

前言

做过几个项目后,我发现 UI 不统一是最让人头疼的问题。颜色到处硬编码,组件样式不一致,后期改起来要改几十个文件。

这次做 CleanMark AI,我花了一天时间建立设计规范和主题系统。虽然前期多花了时间,但后面开发效率提升了至少 30%。

这篇文章我会分享如何建立一套可维护的 UI 系统。
请添加图片描述


一、设计规范制定

1.1 颜色系统

CleanMark AI 采用深色主题,主色调是紫色。我参考了需求文档,整理出完整的颜色规范:

主色调:

  • 主紫色:#8B5CF6 - 按钮、高亮、选中状态
  • 深紫色:#7C3AED - 按钮按下状态
  • 浅紫色:#A78BFA - 辅助元素

功能色:

  • 成功色:#22C55E - 成功提示、已完成状态
  • 警告色:#F59E0B - 积分、警告提示
  • 危险色:#EF4444 - 错误提示、删除操作
  • 信息色:#60A5FA - 信息提示

背景色:

  • 主背景:#0F1115 - 页面背景
  • 次背景:#0F172A - 备用背景
  • 卡片背景:#1A1D24 - 卡片、弹窗背景
  • 卡片背景2:#1C1C1E - 备用卡片背景

文字色:

  • 主文字:#FFFFFF - 标题、重要文字
  • 次文字:#BAC2D2 - 描述文字
  • 辅助文字:#9CA3AF - 提示文字
  • 禁用文字:#6B7280 - 禁用状态

1.2 颜色常量定义

// lib/app/app_colors.dart
import 'package:flutter/material.dart';

/// 应用全局颜色常量
class AppColors {
  AppColors._();  // 私有构造函数,防止实例化

  // 背景色
  static const Color bgDark = Color(0xFF0F1115);
  static const Color bgDark2 = Color(0xFF0F172A);
  static const Color cardDark = Color(0xFF1A1D24);
  static const Color cardDark2 = Color(0xFF1C1C1E);

  // 主色调
  static const Color purple = Color(0xFF8B5CF6);
  static const Color purpleLight = Color(0xFFA78BFA);
  static const Color purpleDark = Color(0xFF7C3AED);

  // 功能色
  static const Color gold = Color(0xFFF59E0B);
  static const Color orange = Color(0xFFEC5B13);
  static const Color green = Color(0xFF22C55E);
  static const Color red = Color(0xFFEF4444);
  static const Color blue = Color(0xFF60A5FA);

  // 灰度色
  static const Color gray200 = Color(0xFFBAC2D2);
  static const Color gray400 = Color(0xFF9CA3AF);
  static const Color gray500 = Color(0xFF6B7280);
  static const Color gray600 = Color(0xFF4B5563);
  static const Color gray700 = Color(0xFF374151);
  static const Color gray800 = Color(0xFF1F2937);

  // 基础色
  static const Color white = Colors.white;
  static const Color black = Colors.black;
}

为什么要定义颜色常量?

  1. 统一管理:所有颜色在一个文件中,修改方便
  2. 语义化AppColors.purpleColor(0xFF8B5CF6) 更易读
  3. 避免硬编码:不会出现同一个颜色写了多个不同的值
  4. IDE 支持:可以自动补全,减少拼写错误

1.3 间距与圆角规范

间距系统(8px 基准):

// lib/core/constants/spacing.dart
class Spacing {
  Spacing._();

  static const double xs = 4.0;   // 超小间距
  static const double sm = 8.0;   // 小间距
  static const double md = 16.0;  // 中等间距(默认)
  static const double lg = 24.0;  // 大间距
  static const double xl = 32.0;  // 超大间距
  static const double xxl = 48.0; // 特大间距
}

圆角规范:

// lib/core/constants/radius.dart
class AppRadius {
  AppRadius._();

  static const double sm = 8.0;   // 小圆角
  static const double md = 12.0;  // 中等圆角
  static const double lg = 16.0;  // 大圆角(按钮)
  static const double xl = 20.0;  // 超大圆角(卡片)
  static const double full = 999.0; // 完全圆角(头像)
}

使用示例:

Container(
  padding: const EdgeInsets.all(Spacing.md),
  margin: const EdgeInsets.symmetric(
    horizontal: Spacing.md,
    vertical: Spacing.sm,
  ),
  decoration: BoxDecoration(
    color: AppColors.cardDark,
    borderRadius: BorderRadius.circular(AppRadius.xl),
  ),
  child: Text('内容'),
)

二、主题系统实现

2.1 ThemeData 配置

Flutter 提供了 ThemeData 来统一管理主题,我们基于此构建自己的主题系统:

// lib/app/theme.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'app_colors.dart';

/// 全局主题配置
class AppTheme {
  AppTheme._();

  static ThemeData get dark => ThemeData(
    // 基础配置
    brightness: Brightness.dark,
    scaffoldBackgroundColor: AppColors.bgDark,
    useMaterial3: true,

    // 颜色方案
    colorScheme: const ColorScheme.dark(
      primary: AppColors.purple,      // 主色
      secondary: AppColors.gold,      // 次要色
      surface: AppColors.cardDark,    // 表面色(卡片)
      error: AppColors.red,           // 错误色
    ),

    // AppBar 主题
    appBarTheme: const AppBarTheme(
      backgroundColor: AppColors.bgDark,
      elevation: 0,  // 无阴影
      centerTitle: true,
      titleTextStyle: TextStyle(
        color: AppColors.white,
        fontSize: 18,
        fontWeight: FontWeight.w600,
      ),
      iconTheme: IconThemeData(color: AppColors.white),
      systemOverlayStyle: SystemUiOverlayStyle(
        statusBarColor: Colors.transparent,
        statusBarIconBrightness: Brightness.light,
      ),
    ),

    // 文字主题
    textTheme: const TextTheme(
      displayLarge: TextStyle(
        fontSize: 32,
        fontWeight: FontWeight.bold,
        color: AppColors.white,
      ),
      displayMedium: TextStyle(
        fontSize: 28,
        fontWeight: FontWeight.bold,
        color: AppColors.white,
      ),
      headlineMedium: TextStyle(
        fontSize: 24,
        fontWeight: FontWeight.w600,
        color: AppColors.white,
      ),
      titleLarge: TextStyle(
        fontSize: 20,
        fontWeight: FontWeight.w600,
        color: AppColors.white,
      ),
      titleMedium: TextStyle(
        fontSize: 16,
        fontWeight: FontWeight.w500,
        color: AppColors.white,
      ),
      bodyLarge: TextStyle(
        fontSize: 16,
        color: AppColors.gray200,
      ),
      bodyMedium: TextStyle(
        fontSize: 14,
        color: AppColors.gray400,
      ),
      labelLarge: TextStyle(
        fontSize: 14,
        fontWeight: FontWeight.w500,
        color: AppColors.white,
      ),
    ),

    // 输入框主题
    inputDecorationTheme: InputDecorationTheme(
      filled: true,
      fillColor: AppColors.cardDark,
      border: OutlineInputBorder(
        borderRadius: BorderRadius.circular(12),
        borderSide: BorderSide.none,
      ),
      contentPadding: const EdgeInsets.symmetric(
        horizontal: 16,
        vertical: 12,
      ),
    ),

    // 按钮主题
    elevatedButtonTheme: ElevatedButtonThemeData(
      style: ElevatedButton.styleFrom(
        backgroundColor: AppColors.purple,
        foregroundColor: AppColors.white,
        padding: const EdgeInsets.symmetric(
          horizontal: 24,
          vertical: 12,
        ),
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(16),
        ),
        elevation: 0,
      ),
    ),

    // 卡片主题
    cardTheme: CardTheme(
      color: AppColors.cardDark,
      elevation: 0,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(20),
      ),
    ),
  );
}

2.2 应用主题

// lib/app/app.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'theme.dart';

class App extends StatelessWidget {
  final GoRouter router;

  const App({required this.router, super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'CleanMark AI',
      theme: AppTheme.dark,  // 应用主题
      routerConfig: router,
      debugShowCheckedModeBanner: false,
    );
  }
}

三、渐变效果实现

3.1 常用渐变定义

CleanMark AI 大量使用渐变效果,我把常用的渐变封装成常量:

// lib/core/constants/gradients.dart
import 'package:flutter/material.dart';
import '../../app/app_colors.dart';

class AppGradients {
  AppGradients._();

  /// 紫色渐变(主按钮)
  static const LinearGradient purple = LinearGradient(
    colors: [AppColors.purple, AppColors.purpleDark],
    begin: Alignment.topLeft,
    end: Alignment.bottomRight,
  );

  /// 橙色渐变(次要按钮)
  static const LinearGradient orange = LinearGradient(
    colors: [AppColors.orange, AppColors.orangeAlt],
    begin: Alignment.topLeft,
    end: Alignment.bottomRight,
  );

  /// 深色渐变(背景)
  static const LinearGradient darkBg = LinearGradient(
    colors: [Color(0xFF1e0b4a), AppColors.black],
    begin: Alignment.topCenter,
    end: Alignment.bottomCenter,
  );

  /// 卡片渐变
  static const LinearGradient card = LinearGradient(
    colors: [AppColors.cardDark, AppColors.cardDark2],
    begin: Alignment.topLeft,
    end: Alignment.bottomRight,
  );
}

3.2 渐变按钮组件

// lib/shared/widgets/gradient_button.dart
import 'package:flutter/material.dart';

class GradientButton extends StatelessWidget {
  final String text;
  final VoidCallback? onPressed;
  final Gradient gradient;
  final double height;
  final double borderRadius;

  const GradientButton({
    required this.text,
    required this.onPressed,
    this.gradient = AppGradients.purple,
    this.height = 48,
    this.borderRadius = 16,
    super.key,
  });

  
  Widget build(BuildContext context) {
    return Container(
      height: height,
      decoration: BoxDecoration(
        gradient: onPressed != null ? gradient : null,
        color: onPressed == null ? AppColors.gray700 : null,
        borderRadius: BorderRadius.circular(borderRadius),
      ),
      child: Material(
        color: Colors.transparent,
        child: InkWell(
          onTap: onPressed,
          borderRadius: BorderRadius.circular(borderRadius),
          child: Center(
            child: Text(
              text,
              style: const TextStyle(
                color: AppColors.white,
                fontSize: 16,
                fontWeight: FontWeight.w600,
              ),
            ),
          ),
        ),
      ),
    );
  }
}

使用示例:

GradientButton(
  text: '立即开始',
  gradient: AppGradients.purple,
  onPressed: () {
    context.push('/image-upload');
  },
)

本篇小结(第一部分)

这部分我们完成了:

  1. ✅ 颜色系统设计与常量定义
  2. ✅ 间距与圆角规范
  3. ✅ ThemeData 主题配置
  4. ✅ 渐变效果封装

下一部分我会继续讲解常用组件的封装和响应式布局。


四、常用组件封装

4.1 状态徽章组件

历史记录页面需要显示任务状态(已完成、处理中、已取消),我封装了一个通用的状态徽章组件:

// lib/shared/widgets/status_badge.dart
import 'package:flutter/material.dart';
import '../../app/app_colors.dart';

class StatusBadge extends StatelessWidget {
  final String text;
  final Color color;

  const StatusBadge({
    required this.text,
    required this.color,
    super.key,
  });

  // 工厂构造函数:根据状态自动选择颜色
  factory StatusBadge.fromStatus(String status) {
    Color color;
    String text;

    switch (status) {
      case 'completed':
      case 'DONE':
        color = AppColors.green;
        text = '已完成';
        break;
      case 'processing':
      case 'PROCESSING':
        color = AppColors.gold;
        text = '处理中';
        break;
      case 'cancelled':
      case 'CANCELLED':
        color = AppColors.gray400;
        text = '已取消';
        break;
      default:
        color = AppColors.gray400;
        text = '未知';
    }

    return StatusBadge(text: text, color: color);
  }

  
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
      decoration: BoxDecoration(
        color: color.withOpacity(0.1),
        borderRadius: BorderRadius.circular(12),
        border: Border.all(color: color.withOpacity(0.2)),
      ),
      child: Text(
        text,
        style: TextStyle(
          color: color,
          fontSize: 10,
          fontWeight: FontWeight.bold,
        ),
      ),
    );
  }
}

使用示例:

// 方式1:直接指定颜色和文字
StatusBadge(text: '已完成', color: AppColors.green)

// 方式2:根据状态自动选择(推荐)
StatusBadge.fromStatus(task.status)

4.2 记录卡片组件

历史记录列表中的卡片组件,展示任务信息:

// lib/features/history/widgets/record_card.dart
class RecordCard extends StatelessWidget {
  final TaskModel task;
  final String time;
  final String statusText;
  final VoidCallback? onTap;

  const RecordCard({
    required this.task,
    required this.time,
    required this.statusText,
    this.onTap,
    super.key,
  });

  Color get _statusColor {
    switch (task.status) {
      case 'completed':
      case 'DONE':
        return AppColors.green;
      case 'processing':
      case 'PROCESSING':
        return AppColors.gold;
      default:
        return AppColors.gray400;
    }
  }

  String get _fileName {
    if (task.inputFile != null) {
      return task.inputFile!.split('/').last;
    }
    return '未知文件';
  }

  
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Container(
        margin: const EdgeInsets.only(bottom: 12),
        padding: const EdgeInsets.all(16),
        decoration: BoxDecoration(
          color: AppColors.slate800.withOpacity(0.4),
          borderRadius: BorderRadius.circular(16),
          border: Border.all(
            color: AppColors.slate700.withOpacity(0.3),
          ),
        ),
        child: Column(
          children: [
            // 顶部:图标 + 标题 + 状态
            Row(
              children: [
                // 图标
                Container(
                  width: 40,
                  height: 40,
                  decoration: BoxDecoration(
                    borderRadius: BorderRadius.circular(12),
                    color: AppColors.purple.withOpacity(0.1),
                  ),
                  child: const Icon(
                    Icons.image,
                    color: AppColors.purple,
                    size: 20,
                  ),
                ),
                const SizedBox(width: 12),
                // 标题和时间
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      const Text(
                        '图片去水印',
                        style: TextStyle(
                          color: Color(0xFFF1F5F9),
                          fontWeight: FontWeight.w600,
                        ),
                      ),
                      const SizedBox(height: 2),
                      Text(
                        time,
                        style: const TextStyle(
                          color: AppColors.gray500,
                          fontSize: 11,
                        ),
                      ),
                    ],
                  ),
                ),
                // 状态和积分
                Column(
                  crossAxisAlignment: CrossAxisAlignment.end,
                  children: [
                    StatusBadge.fromStatus(task.status),
                    const SizedBox(height: 4),
                    const Text(
                      '-1 积分',
                      style: TextStyle(
                        color: AppColors.red,
                        fontWeight: FontWeight.bold,
                        fontSize: 14,
                      ),
                    ),
                  ],
                ),
              ],
            ),
            const SizedBox(height: 12),
            // 底部:文件名
            Container(
              padding: const EdgeInsets.only(top: 12),
              decoration: BoxDecoration(
                border: Border(
                  top: BorderSide(
                    color: AppColors.slate700.withOpacity(0.5),
                  ),
                ),
              ),
              child: Row(
                children: [
                  Expanded(
                    child: Text(
                      _fileName,
                      style: const TextStyle(
                        color: AppColors.gray400,
                        fontSize: 12,
                        fontStyle: FontStyle.italic,
                      ),
                      overflow: TextOverflow.ellipsis,
                    ),
                  ),
                  if (onTap != null)
                    const Icon(
                      Icons.chevron_right,
                      color: AppColors.gray500,
                      size: 16,
                    ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

设计要点:

  1. 视觉层次:通过颜色、字号、字重区分主次信息
  2. 状态反馈:点击时有视觉反馈(GestureDetector)
  3. 信息密度:在有限空间内展示关键信息
  4. 可扩展性:通过参数控制显示内容

五、响应式布局

5.1 屏幕尺寸适配

不同设备的屏幕尺寸差异很大,需要做响应式适配:

// lib/core/utils/screen_utils.dart
import 'package:flutter/material.dart';

class ScreenUtils {
  ScreenUtils._();

  /// 获取屏幕宽度
  static double width(BuildContext context) {
    return MediaQuery.of(context).size.width;
  }

  /// 获取屏幕高度
  static double height(BuildContext context) {
    return MediaQuery.of(context).size.height;
  }

  /// 是否为小屏设备(宽度 < 360)
  static bool isSmallScreen(BuildContext context) {
    return width(context) < 360;
  }

  /// 是否为平板设备(宽度 >= 600)
  static bool isTablet(BuildContext context) {
    return width(context) >= 600;
  }

  /// 根据屏幕宽度返回不同的值
  static T responsive<T>(
    BuildContext context, {
    required T mobile,
    T? tablet,
  }) {
    if (isTablet(context) && tablet != null) {
      return tablet;
    }
    return mobile;
  }
}

使用示例:

// 根据屏幕尺寸调整布局
Widget build(BuildContext context) {
  return Container(
    padding: EdgeInsets.all(
      ScreenUtils.responsive(
        context,
        mobile: 16.0,
        tablet: 24.0,
      ),
    ),
    child: Column(
      children: [
        // 小屏设备显示单列,平板显示双列
        if (ScreenUtils.isTablet(context))
          Row(
            children: [
              Expanded(child: _buildCard1()),
              const SizedBox(width: 16),
              Expanded(child: _buildCard2()),
            ],
          )
        else
          Column(
            children: [
              _buildCard1(),
              const SizedBox(height: 16),
              _buildCard2(),
            ],
          ),
      ],
    ),
  );
}

5.2 安全区域适配

iOS 设备有刘海屏、底部横条,需要使用 SafeArea 避免内容被遮挡:

Scaffold(
  body: SafeArea(
    child: Column(
      children: [
        // 内容
      ],
    ),
  ),
)

注意事项:

  • 全屏页面(如启动页)不需要 SafeArea
  • 有 AppBar 的页面,AppBar 会自动处理顶部安全区域
  • 底部导航栏会自动处理底部安全区域

六、实战案例:首页卡片设计

6.1 Hero Banner 卡片

首页顶部的大卡片,使用渐变背景和阴影效果:

// lib/features/home/widgets/hero_banner.dart
class HeroBanner extends StatelessWidget {
  final VoidCallback onTap;

  const HeroBanner({required this.onTap, super.key});

  
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Container(
        margin: const EdgeInsets.all(16),
        padding: const EdgeInsets.all(24),
        decoration: BoxDecoration(
          gradient: const LinearGradient(
            colors: [
              Color(0xFF8B5CF6),
              Color(0xFF7C3AED),
            ],
            begin: Alignment.topLeft,
            end: Alignment.bottomRight,
          ),
          borderRadius: BorderRadius.circular(20),
          boxShadow: [
            BoxShadow(
              color: AppColors.purple.withOpacity(0.3),
              blurRadius: 20,
              offset: const Offset(0, 10),
            ),
          ],
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // 图标
            Container(
              width: 48,
              height: 48,
              decoration: BoxDecoration(
                color: Colors.white.withOpacity(0.2),
                borderRadius: BorderRadius.circular(12),
              ),
              child: const Icon(
                Icons.auto_fix_high,
                color: Colors.white,
                size: 24,
              ),
            ),
            const SizedBox(height: 16),
            // 标题
            const Text(
              'AI 智能去水印',
              style: TextStyle(
                color: Colors.white,
                fontSize: 24,
                fontWeight: FontWeight.bold,
              ),
            ),
            const SizedBox(height: 8),
            // 描述
            Text(
              '一键移除图片和视频中的水印',
              style: TextStyle(
                color: Colors.white.withOpacity(0.9),
                fontSize: 14,
              ),
            ),
            const SizedBox(height: 16),
            // 按钮
            Container(
              padding: const EdgeInsets.symmetric(
                horizontal: 16,
                vertical: 8,
              ),
              decoration: BoxDecoration(
                color: Colors.white,
                borderRadius: BorderRadius.circular(12),
              ),
              child: const Text(
                '立即体验 →',
                style: TextStyle(
                  color: AppColors.purple,
                  fontWeight: FontWeight.w600,
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

设计亮点:

  1. 渐变背景:紫色渐变营造科技感
  2. 阴影效果:增加层次感和立体感
  3. 半透明元素:图标背景使用半透明白色
  4. 视觉引导:箭头引导用户点击

本篇完整小结

这篇文章我们完成了:

  1. ✅ 颜色系统设计(主色、功能色、背景色)
  2. ✅ 间距与圆角规范(8px 基准)
  3. ✅ ThemeData 主题配置
  4. ✅ 渐变效果封装
  5. ✅ 常用组件封装(状态徽章、记录卡片)
  6. ✅ 响应式布局适配
  7. ✅ 实战案例:Hero Banner 设计

关键要点:

  • 统一管理颜色和间距,避免硬编码
  • 封装常用组件,提高复用性
  • 使用 ThemeData 统一主题
  • 做好响应式适配,支持不同屏幕尺寸

思考题

  1. 为什么要定义颜色常量而不是直接写 Color(0xFF…)?
  2. 什么时候应该封装成组件,什么时候直接写在页面里?
  3. 如何平衡设计美观和开发效率?

下一篇预告:第04篇 - HarmonyOS 平台差异与适配策略

更多推荐