在上一篇文章中,我们完成了交易记录列表的开发。当用户点击某条交易记录时,需要查看该笔交易的详细信息。本篇将实现交易详情页面,展示完整的交易信息并提供编辑和删除功能。
请添加图片描述

功能需求分析

交易详情页面需要满足以下需求:

  1. 展示交易的分类图标和名称
  2. 突出显示交易金额(收入为绿色,支出为红色)
  3. 显示关联的账户信息
  4. 显示交易日期和创建时间
  5. 如有备注则显示备注内容
  6. 提供编辑交易的功能
  7. 提供删除交易的功能(需二次确认)

这些功能看起来简单,但细节处理很重要。好的详情页应该让用户一眼就能看到最重要的信息,同时提供便捷的操作入口。

页面结构设计

整个页面采用卡片式布局,分为多个主要区域:

  • 顶部卡片:展示分类图标、名称和金额,这是最重要的信息
  • 详情卡片:展示账户、日期、时间、备注等辅助信息
  • 操作区域:编辑和删除按钮

这种布局让信息层次分明,用户可以快速获取关键信息,也能方便地进行操作。

创建交易详情页面

lib/app/modules/transaction/ 目录下创建 transaction_detail_page.dart

import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import '../../core/services/transaction_service.dart';
import '../../core/services/category_service.dart';
import '../../core/services/account_service.dart';
import '../../core/services/storage_service.dart';
import '../../data/models/transaction_model.dart';

const _primaryColor = Color(0xFF2E7D32);
const _incomeColor = Color(0xFF4CAF50);
const _expenseColor = Color(0xFFE53935);
const _textSecondary = Color(0xFF757575);

导入部分包含了 Flutter 核心库、屏幕适配库、GetX、日期格式化库,以及项目内部的各个服务。TransactionService 用于删除交易,CategoryService 和 AccountService 用于获取关联的分类和账户信息,StorageService 提供货币符号。

颜色常量定义在文件顶部,和其他页面保持一致。_incomeColor 用绿色表示收入,_expenseColor 用红色表示支出,这种配色方案贯穿整个应用,用户已经形成了认知习惯。_textSecondary 用于次要文字,灰色调不会抢主要内容的风头。

页面主体结构

页面使用 StatelessWidget,因为所有数据都是从路由参数获取的,不需要管理本地状态:

class TransactionDetailPage extends StatelessWidget {
  const TransactionDetailPage({super.key});

  
  Widget build(BuildContext context) {
    final transaction = Get.arguments as TransactionModel;
    final categoryService = Get.find<CategoryService>();
    final accountService = Get.find<AccountService>();
    final transactionService = Get.find<TransactionService>();
    final storage = Get.find<StorageService>();
    final category = categoryService.getCategoryById(transaction.categoryId);
    final account = accountService.getAccountById(transaction.accountId);

    return Scaffold(
      appBar: AppBar(
        title: const Text('交易详情'),
        actions: [
          IconButton(
            icon: const Icon(Icons.delete),
            onPressed: () => _showDeleteDialog(transactionService, transaction.id),
          ),
        ],
      ),

Get.arguments 获取从列表页传递过来的交易数据,类型转换为 TransactionModel。然后通过 Get.find 获取各个服务的实例,再根据交易的 categoryId 和 accountId 查找关联的分类和账户信息。

AppBar 右侧放置删除按钮,使用红色的删除图标。点击时调用 _showDeleteDialog 方法显示确认对话框,防止用户误操作。删除是危险操作,必须有二次确认。

页面主体使用 SingleChildScrollView 包裹,确保内容超出屏幕时可以滚动:

      body: SingleChildScrollView(
        padding: EdgeInsets.all(16.w),
        child: Column(
          children: [
            _buildHeaderCard(transaction, category, storage),
            SizedBox(height: 16.h),
            _buildDetailCard(transaction, account),
            SizedBox(height: 16.h),
            _buildActionButtons(transaction, transactionService),
          ],
        ),
      ),
    );
  }
}

页面分为三个主要区域:顶部信息卡片、详情卡片、操作按钮。每个区域之间用 SizedBox 添加 16.h 的间距,保持视觉上的呼吸感。padding 设为 16.w,和其他页面保持一致的边距。

顶部信息卡片

顶部卡片是页面的视觉焦点,展示分类图标和交易金额:

Widget _buildHeaderCard(
  TransactionModel transaction, 
  category, 
  StorageService storage
) {
  return Card(
    child: Padding(
      padding: EdgeInsets.all(24.w),
      child: Column(
        children: [
          CircleAvatar(
            radius: 32.r,
            backgroundColor: (category?.color ?? Colors.grey).withOpacity(0.2),
            child: Icon(
              category?.icon ?? Icons.help, 
              size: 32.sp, 
              color: category?.color ?? Colors.grey
            ),
          ),
          SizedBox(height: 16.h),
          Text(
            category?.name ?? '未知分类', 
            style: TextStyle(fontSize: 18.sp, fontWeight: FontWeight.w600)
          ),

Card 组件包裹整个顶部区域,padding 设为 24.w 比普通卡片稍大,让内容有更多呼吸空间。CircleAvatar 显示分类图标,radius 设为 32.r 是一个较大的尺寸,让图标更醒目。

背景色使用分类颜色的 20% 透明度,既有颜色又不会太抢眼。category?.color 使用空安全操作符,如果分类不存在就用灰色作为默认值。分类名称用 18.sp 的中等字号和 w600 的粗细,比普通文字更突出。

金额显示部分,根据交易类型显示不同的颜色和符号:

          SizedBox(height: 8.h),
          Text(
            '${transaction.type == TransactionType.income ? '+' : '-'}'
            '${storage.currency}${transaction.amount.toStringAsFixed(2)}',
            style: TextStyle(
              fontSize: 32.sp, 
              fontWeight: FontWeight.bold,
              color: transaction.type == TransactionType.income 
                ? _incomeColor 
                : _expenseColor,
            ),
          ),
        ],
      ),
    ),
  );
}

金额是页面上最大的文字,用 32.sp 的字号和 bold 粗体。收入前面加 + 号并显示绿色,支出前面加 - 号并显示红色。这种视觉区分让用户一眼就能识别交易类型,不需要额外的标签说明。

toStringAsFixed(2) 保留两位小数,让金额显示更规范。storage.currency 获取用户设置的货币符号,支持多币种显示。三元表达式根据交易类型选择颜色,代码简洁明了。

详情信息卡片

详情卡片采用列表形式展示各项辅助信息:

Widget _buildDetailCard(TransactionModel transaction, account) {
  return Card(
    child: Column(
      children: [
        _buildDetailItem(
          Icons.account_balance_wallet, 
          '账户', 
          account?.name ?? '未知'
        ),
        const Divider(height: 1),
        _buildDetailItem(
          Icons.calendar_today, 
          '日期', 
          DateFormat('yyyy-MM-dd HH:mm').format(transaction.date)
        ),

Card 包裹所有详情项,Column 垂直排列。每个详情项之间用 Divider 分隔,height 设为 1 让分隔线尽量细,不会太抢眼。账户信息显示账户名称,如果账户不存在显示"未知"。

日期使用 DateFormat 格式化为 ‘yyyy-MM-dd HH:mm’ 格式,同时显示日期和时间。这个格式清晰易读,用户一眼就能看出交易发生的具体时间。

继续添加创建时间和备注信息:

        const Divider(height: 1),
        _buildDetailItem(
          Icons.access_time, 
          '创建时间', 
          DateFormat('yyyy-MM-dd HH:mm').format(transaction.createdAt)
        ),
        if (transaction.note != null && transaction.note!.isNotEmpty) ...[
          const Divider(height: 1),
          _buildDetailItem(Icons.note, '备注', transaction.note!),
        ],
      ],
    ),
  );
}

创建时间和交易日期可能不同,比如用户补记之前的交易。备注信息使用条件渲染,只有当备注不为空时才显示。if 语句配合展开运算符 … 可以条件性地添加多个 Widget,这是 Dart 的语法特性。

这种设计避免了显示空白的备注行,让界面更简洁。transaction.note! 使用 ! 操作符是因为前面已经判断过不为 null 且不为空。

详情项组件

每个详情项的布局保持一致,左侧图标,中间标签,右侧值:

Widget _buildDetailItem(IconData icon, String label, String value) {
  return Padding(
    padding: EdgeInsets.all(16.w),
    child: Row(
      children: [
        Icon(icon, color: _primaryColor, size: 20.sp),
        SizedBox(width: 12.w),
        Text(
          label, 
          style: TextStyle(fontSize: 14.sp, color: _textSecondary)
        ),
        const Spacer(),

Padding 设为 16.w,和其他组件保持一致的内边距。Row 组件水平排列图标、标签和值。图标使用主题色,size 设为 20.sp 是一个适中的大小。标签用灰色小字,起到说明作用。

Spacer 组件占据中间的空白区域,把值推到右边。这是 Flutter 中实现两端对齐的常用技巧,比设置 MainAxisAlignment.spaceBetween 更灵活。

值的显示部分:

        Flexible(
          child: Text(
            value, 
            style: TextStyle(fontSize: 14.sp), 
            textAlign: TextAlign.right,
            maxLines: 2,
            overflow: TextOverflow.ellipsis,
          )
        ),
      ],
    ),
  );
}

Flexible 包裹右侧文本,当内容过长时可以自动换行或截断,避免溢出。textAlign 设为 right 让文字右对齐,和标签形成视觉上的对称。maxLines 限制最多两行,overflow 设为 ellipsis 超出部分显示省略号。

这种设计既保证了布局的稳定性,又能处理各种长度的内容。比如备注可能很长,用 Flexible 和 maxLines 可以优雅地处理。

操作按钮区域

页面底部放置编辑和删除按钮:

Widget _buildActionButtons(
  TransactionModel transaction, 
  TransactionService service
) {
  return Row(
    children: [
      Expanded(
        child: OutlinedButton.icon(
          onPressed: () => Get.toNamed(
            Routes.addTransaction, 
            arguments: transaction
          ),
          icon: const Icon(Icons.edit),
          label: const Text('编辑'),

Row 组件让两个按钮水平排列,Expanded 让它们平分宽度。OutlinedButton.icon 是带图标的描边按钮,视觉上比实心按钮轻量,适合次要操作。

编辑按钮点击后跳转到添加交易页面,通过 arguments 传递当前交易对象。添加交易页面会检测到有传入数据,自动切换到编辑模式。这种复用设计减少了代码重复。

按钮样式配置:

          style: OutlinedButton.styleFrom(
            padding: EdgeInsets.symmetric(vertical: 12.h),
            side: BorderSide(color: _primaryColor),
          ),
        ),
      ),
      SizedBox(width: 12.w),
      Expanded(
        child: OutlinedButton.icon(
          onPressed: () => _showDeleteDialog(service, transaction.id),
          icon: const Icon(Icons.delete),
          label: const Text('删除'),

padding 设置垂直内边距为 12.h,让按钮有足够的点击区域。side 设置边框颜色为主题色。两个按钮之间用 SizedBox 留出 12.w 的间距。

删除按钮的样式使用红色,强调这是危险操作:

          style: OutlinedButton.styleFrom(
            padding: EdgeInsets.symmetric(vertical: 12.h),
            side: BorderSide(color: _expenseColor),
            foregroundColor: _expenseColor,
          ),
        ),
      ),
    ],
  );
}

foregroundColor 设为红色,让图标和文字都显示红色。side 也设为红色,保持视觉一致。这种颜色区分让用户在操作前就能意识到删除的危险性。

删除确认对话框

删除操作需要二次确认,防止误操作:

void _showDeleteDialog(TransactionService service, String id) {
  Get.dialog(
    AlertDialog(
      title: const Text('删除记录'),
      content: const Text('确定要删除这条记录吗?此操作不可恢复。'),
      actions: [
        TextButton(
          onPressed: () => Get.back(), 
          child: const Text('取消')
        ),
        TextButton(
          onPressed: () {
            service.deleteTransaction(id);
            Get.back();
            Get.back();
            Get.snackbar('成功', '记录已删除');
          },

Get.dialog 显示对话框,AlertDialog 是 Material Design 的标准对话框组件。title 和 content 分别设置标题和内容,内容中说明了删除不可恢复,让用户做出知情的决定。

取消按钮调用 Get.back() 关闭对话框。删除按钮先调用服务删除数据,然后调用两次 Get.back():第一次关闭对话框,第二次返回列表页面。最后显示成功提示。

删除按钮的样式:

          child: Text('删除', style: TextStyle(color: _expenseColor)),
        ),
      ],
    ),
  );
}

删除按钮使用红色文字,和页面上的删除按钮保持一致。这种颜色强调让用户在最后一刻还能意识到这是危险操作,有机会取消。

编辑功能说明

编辑功能复用了添加交易页面,通过参数区分模式:

// 从详情页跳转到编辑页
Get.toNamed(Routes.addTransaction, arguments: transaction);

// 添加交易页面检测参数

void initState() {
  super.initState();
  final transaction = Get.arguments as TransactionModel?;
  if (transaction != null) {
    // 编辑模式,回填数据
    _amountController.text = transaction.amount.toString();
    _type = transaction.type;
    // ... 其他字段
  }
}

这种设计的好处是减少代码重复,新增和编辑的 UI 完全一样,只是数据来源不同。用户体验上也更统一,操作流程是一样的。保存时根据是否有原始数据判断是新增还是更新。

错误处理

处理数据异常情况,比如路由参数为空:


Widget build(BuildContext context) {
  final args = Get.arguments;
  if (args == null || args is! TransactionModel) {
    return Scaffold(
      appBar: AppBar(title: const Text('交易详情')),
      body: const Center(
        child: Text('无效的交易数据')
      ),
    );
  }
  
  final transaction = args;
  // ... 正常渲染
}

这种防御性编程可以避免应用崩溃。如果用户通过深度链接直接访问详情页,或者数据传递出现问题,页面会显示友好的错误提示而不是崩溃。

无障碍支持

为视障用户提供语义化标签:

Semantics(
  label: '交易金额 ${transaction.amount} 元,'
         '${transaction.type == TransactionType.income ? "收入" : "支出"}',
  child: Text(
    '${storage.currency}${transaction.amount.toStringAsFixed(2)}',
    style: TextStyle(fontSize: 32.sp, fontWeight: FontWeight.bold),
  ),
)

Semantics 组件为屏幕阅读器提供描述信息。label 属性包含了金额和类型信息,视障用户可以通过语音了解交易详情。这是应用无障碍支持的重要组成部分。

性能优化

详情页的性能优化主要关注以下几点:

// 1. 使用 const 构造函数减少重建
const Divider(height: 1),
const Text('取消'),

// 2. 避免在 build 中做重复计算
final category = categoryService.getCategoryById(transaction.categoryId);
// 只计算一次,后续直接使用 category 变量

// 3. 图标使用 Material Icons,无需额外资源加载
Icon(Icons.edit, color: _primaryColor)

const 的使用可以让 Flutter 在重建时跳过这些不变的 Widget。关联数据的查找在 build 开头完成,避免在子组件中重复查找。Material Icons 是内置的,不需要网络加载。

小结

交易详情页面虽然功能相对简单,但在设计上需要注意以下几点:

  1. 信息层次分明,金额作为最重要的信息用最大字号显示
  2. 收入支出使用不同颜色区分,形成视觉习惯
  3. 删除等危险操作需要二次确认,防止误操作
  4. 空数据(如备注为空)需要做好条件渲染
  5. 编辑功能复用添加页面,减少代码重复
  6. 考虑错误处理和无障碍支持

下一篇我们将实现分类列表页面,让用户可以管理自己的收支分类。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐