AtomGit Flutter鸿蒙客户端:Issue管理

Issue 与 PR 的统一处理

AtomGit(类 GitHub)API 中,PR 本质上是一种特殊 Issue。本项目的 Issue 模型通过 isPullRequest 字段区分:

dart 复制代码
class Issue {
  final int id;
  final int number;
  final String title;
  final String? body;
  final String state;          // 'open' 或 'closed'
  final UserProfile? user;
  final List<String> labels;
  final int commentsCount;
  final bool isPullRequest;    // 区分 Issue 和 PR
  final DateTime createdAt;
  final DateTime updatedAt;

  factory Issue.fromJson(Map<String, dynamic> json) {
    return Issue(
      id: parseInt(json['id']),
      number: parseInt(json['number']),
      title: parseString(json['title']),
      body: json['body'] as String?,
      state: parseString(json['state']),
      isPullRequest: json['pull_request'] != null,
      labels: (parseList<dynamic>(json, 'labels') ?? [])
          .whereType<Map<String, dynamic>>()
          .map((l) => parseString(l['name']))
          .toList(),
      commentsCount: parseInt(json['comments']),
      // ...
    );
  }
}

isPullRequest 通过检查 JSON 中是否存在 pull_request 键来判定,这对应 GitHub API 的行为。

IssueProvider

加载列表

dart 复制代码
class IssueProvider extends ChangeNotifier {
  final AtomGitApiClient _apiClient;
  List<Issue> _issues = [];
  bool _isLoading = false;
  String? _error;
  String _stateFilter = 'open';

  Future<void> loadIssues(
      String owner, String repo, String type) async {
    _isLoading = true;
    _error = null;
    notifyListeners();

    try {
      final encodedOwner = Uri.encodeComponent(owner);
      final encodedRepo = Uri.encodeComponent(repo);

      final endpoint = type == 'pr'
          ? '/repos/$encodedOwner/$encodedRepo/pulls'
          : '/repos/$encodedOwner/$encodedRepo/issues';

      final response = await _apiClient.get(endpoint, queryParams: {
        'state': _stateFilter,
        'per_page': '30',
      });

      final items = parseList<dynamic>(response.data) ?? [];
      _issues = items
          .whereType<Map<String, dynamic>>()
          .map(Issue.fromJson)
          .where((issue) =>
              type == 'pr' ? issue.isPullRequest : !issue.isPullRequest)
          .toList();
    } on ApiException catch (e) {
      _error = e.message;
    } finally {
      _isLoading = false;
      notifyListeners();
    }
  }
}

Issue 和 PR 使用不同 API 端点(/issues vs /pulls),但返回数据可能混合。通过 isPullRequest 过滤确保类型纯净。

状态过滤

dart 复制代码
void setStateFilter(String state) {
  _stateFilter = state;
  // 筛选后不自动刷新,由 UI 调用 loadIssues
}

Filter 状态存储在 Provider 上,UI 切换后重新调用 loadIssues。

加载详情与评论

dart 复制代码
Future<void> loadDetail(
    String owner, String repo, int number) async {
  _isLoading = true;
  _error = null;
  notifyListeners();

  try {
    final encodedOwner = Uri.encodeComponent(owner);
    final encodedRepo = Uri.encodeComponent(repo);

    // 并行加载 Issue 详情和评论
    final results = await Future.wait([
      _apiClient.get(
        '/repos/$encodedOwner/$encodedRepo/issues/$number',
      ),
      _apiClient.get(
        '/repos/$encodedOwner/$encodedRepo/issues/$number/comments',
        queryParams: {'per_page': '30'},
      ),
    ]);

    final issueData = parseMap(results[0].data);
    if (issueData != null) {
      _detail = Issue.fromJson(issueData);
    }

    final commentsList = parseList<dynamic>(results[1].data) ?? [];
    _comments = commentsList
        .whereType<Map<String, dynamic>>()
        .map(Comment.fromJson)
        .toList();
  } on ApiException catch (e) {
    _error = e.message;
  } finally {
    _isLoading = false;
    notifyListeners();
  }
}

Future.wait 并发请求 Issue 详情和评论列表,减少加载等待时间。

IssueListScreen

过滤器 Chips

使用 Material FilterChip 实现状态切换:

dart 复制代码
AppBar(
  title: Text(type == 'pr' ? 'Pull Requests' : 'Issues'),
  bottom: PreferredSize(
    preferredSize: const Size.fromHeight(48),
    child: Padding(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      child: Row(children: [
        FilterChip(
          label: const Text('进行中'),
          selected: provider.stateFilter == 'open',
          onSelected: (_) {
            provider.setStateFilter('open');
            provider.loadIssues(owner, name, type);
          },
        ),
        const SizedBox(width: 8),
        FilterChip(
          label: const Text('已完成'),
          selected: provider.stateFilter == 'closed',
          onSelected: (_) {
            provider.setStateFilter('closed');
            provider.loadIssues(owner, name, type);
          },
        ),
      ]),
    ),
  ),
)

Issue 列表项

dart 复制代码
Widget _buildIssueTile(Issue issue, String owner, String name) {
  final isOpen = issue.state == 'open';

  return ListTile(
    leading: Icon(
      isOpen ? Icons.error_outline : Icons.check_circle_outline,
      color: isOpen ? Colors.green : Colors.red,
    ),
    title: Text('#${issue.number} ${issue.title}',
        maxLines: 2, overflow: TextOverflow.ellipsis),
    subtitle: Text(
      '${issue.user?.login ?? 'unknown'} · '
      '${DateFormatter.relative(issue.createdAt)} · '
      '${issue.commentsCount} 条评论',
    ),
    onTap: () {
      final route = type == 'pr'
          ? '/repo/pulls/detail'
          : '/repo/issues/detail';
      Navigator.pushNamed(context, route, arguments: {
        'owner': owner, 'name': name, 'number': issue.number,
      });
    },
  );
}

打开/关闭状态用不同颜色图标区分,副标题展示作者、时间和评论数。

IssueDetailScreen

头部信息

dart 复制代码
Widget _buildHeader(Issue issue) {
  return Card(
    margin: const EdgeInsets.all(16),
    child: Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Row(children: [
            Icon(
              issue.state == 'open'
                  ? Icons.error_outline
                  : Icons.check_circle_outline,
              color: issue.state == 'open' ? Colors.green : Colors.red,
            ),
            const SizedBox(width: 8),
            Expanded(child: Text(issue.title,
                style: Theme.of(context).textTheme.titleMedium)),
          ]),
          const SizedBox(height: 12),
          Row(children: [
            UserAvatar(
                avatarUrl: issue.user?.avatarUrl,
                name: issue.user?.login,
                size: 24),
            const SizedBox(width: 8),
            Text(issue.user?.login ?? ''),
            const Spacer(),
            Text(DateFormatter.relative(issue.createdAt),
                style: Theme.of(context).textTheme.bodySmall),
          ]),
          if (issue.labels.isNotEmpty) ...[
            const SizedBox(height: 8),
            Wrap(spacing: 4, runSpacing: 4, children: issue.labels
                .map((label) => Chip(label: Text(label)))
                .toList()),
          ],
        ],
      ),
    ),
  );
}

内容与评论

dart 复制代码
Widget _buildBody(Issue issue, List<Comment> comments) {
  return ListView(
    padding: const EdgeInsets.all(16),
    children: [
      MarkdownViewer(markdown: issue.body ?? '*无描述*'),
      const Divider(height: 32),
      Text('${comments.length} 条评论',
          style: Theme.of(context).textTheme.titleSmall),
      ...comments.map((comment) => _CommentWidget(comment: comment)),
    ],
  );
}

评论组件

dart 复制代码
class _CommentWidget extends StatelessWidget {
  final Comment comment;

  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.symmetric(vertical: 8),
      child: Padding(
        padding: const EdgeInsets.all(12),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(children: [
              UserAvatar(
                  avatarUrl: comment.user?.avatarUrl,
                  name: comment.user?.login,
                  size: 24),
              const SizedBox(width: 8),
              Text(comment.user?.login ?? ''),
              const Spacer(),
              Text(DateFormatter.relative(comment.createdAt),
                  style: Theme.of(context).textTheme.bodySmall),
            ]),
            const SizedBox(height: 8),
            MarkdownViewer(markdown: comment.body),
          ],
        ),
      ),
    );
  }
}

每条评论展示头像、用户名、时间和 Markdown 渲染的正文。

Comment 模型

dart 复制代码
class Comment {
  final int id;
  final String body;
  final UserProfile? user;
  final DateTime createdAt;
  final DateTime updatedAt;

  factory Comment.fromJson(Map<String, dynamic> json) {
    return Comment(
      id: parseInt(json['id']),
      body: parseString(json['body']),
      user: json['user'] != null
          ? UserProfile.fromJson(json['user'] as Map<String, dynamic>)
          : null,
      createdAt: parseDateTime(json['created_at']) ?? DateTime.now(),
      updatedAt: parseDateTime(json['updated_at']) ?? DateTime.now(),
    );
  }
}
相关推荐
晶捷软件6 小时前
从 IT 架构视角看 ERP 选型:用友、金蝶与鼎捷生态(含晶捷智能)的技术差异
架构·智能制造·erp·制造业信息化
@大迁世界7 小时前
Firefox 本地模式:localhost 再见了 ?
前端·架构
深蓝AI7 小时前
总榜第一不等于最划算:Intelligence Index v5 换代后,模型选型要算的三笔账
程序员·架构
天远数科7 小时前
零信任架构实战:基于天远企业股权变更构建自动化商业银行对公授信存续期股权异动与资产抽逃预警网关
运维·人工智能·架构·自动化
好的佩奇8 小时前
Dart 之类修饰符
android·flutter·dart
AI你一生一世8 小时前
当“暂停“成为架构决策:从签证系统停摆看高可用设计的边界
架构·系统架构·流量控制·幂等性·高可用设计·优雅降级·状态一致性
2501_948122638 小时前
开源鸿蒙平台 CMP 三方库「kmp-showcase-compose」适配全流程
华为·开源·harmonyos
OH_TPC9 小时前
HarmonyOS APP开发---“旅图“旅行摄影社区App,需要用到这个库
华为·harmonyos·鸿蒙
独隅11 小时前
HarmonyOS 系统能力与体验深度评测
华为·harmonyos·harmonyos 7.0
2601_9495758611 小时前
开源鸿蒙平台 CMP 三方库「Persian-Date-picker」适配全流程
华为·开源·harmonyos