欢迎光临
我们一直在努力

AtomGit Flutter鸿蒙客户端:首页与仓库列表

在这里插入图片描述

概述

首页是整个应用的门面,承载着用户进入应用后的第一印象。在 AtomGit Flutter 客户端中,首页的设计需要同时兼顾两种用户状态:尚未登录的访客和已认证的用户。这两种状态对应着完全不同的信息架构和交互路径。

访客状态下的首页更像一个品牌展示和功能引导页,告知用户这个应用能做什么、登录后能获得什么。而已登录用户的首页则是一个高效的工作台,聚合了用户自己的仓库和平台热门仓库,方便快速访问和操作。

HomeTab 的整体设计思路

HomeTab 是底部导航栏的第一个 Tab,用户启动应用后默认展示。它的设计核心理念是"根据身份展示不同的内容"。这个理念的实现依赖于 Provider 状态管理框架 —— 通过监听 AuthProvider 的登录状态变化,在同一个 Widget 中动态切换 UI。

class HomeTab extends StatefulWidget {
const HomeTab({super.key});


State<HomeTab> createState() => _HomeTabState();
}

class _HomeTabState extends State<HomeTab> {
List<Repository>? _trendingRepos;
List<Repository>? _userRepos;
bool _isLoadingTrending = false;
bool _isLoadingUserRepos = false;
String? _error;


Widget build(BuildContext context) {
final isLoggedIn = context.watch<AuthProvider>().isLoggedIn;

return Scaffold(
appBar: AppBar(
title: const Text('AtomGit'),
actions: [
if (isLoggedIn)
IconButton(
icon: const Icon(Icons.search),
onPressed: () {
// 在首页顶部提供搜索入口
},
),
],
),
body: RefreshIndicator(
onRefresh: _onRefresh,
child: isLoggedIn ? _buildAuthedBody() : _buildWelcome(context),
),
);
}
}

注意 context.watch<AuthProvider>().isLoggedIn 的使用。这不是一次性读取,而是建立了一个持续订阅关系 —— 当 AuthProvider 调用 notifyListeners() 时,HomeTab 的 build 方法会自动重新执行。这意味着用户登录或登出时,首页 UI 会无缝切换,不需要手动刷新或导航。

双重数据加载策略

首页在已登录状态下需要同时加载两个数据源:热门仓库和用户自己的仓库。这两个数据源的加载时机和失败处理相互独立,不能因为一个失败而阻塞另一个。

为什么需要两层触发机制

在 Flutter 中,initState 和 build 之间有一个微妙的时间差。initState 在 Widget 创建时立即调用,但此时可能 AuthProvider 尚未完成 session 恢复(token 从本地存储中读取)。如果只在 initState 中触发数据加载,可能出现这样的时序问题:

  • initState 调用 → AuthProvider 尚未恢复 token → 跳过加载
  • AuthProvider 恢复 token → notifyListeners() → HomeTab 重建
  • build 执行 → 但没人重新触发数据加载 → 页面空白
  • 为了解决这个问题,项目采用了两层触发机制:


    void initState() {
    super.initState();
    // 第一层:initState 中首次尝试
    _loadTrending();
    }


    Widget build(BuildContext context) {
    final isLoggedIn = context.watch<AuthProvider>().isLoggedIn;

    // 第二层:如果 build 时发现应该加载但没加载,补充触发
    if (isLoggedIn && _trendingRepos == null && !_isLoadingTrending) {
    WidgetsBinding.instance.addPostFrameCallback((_) => _loadTrending());
    }
    if (isLoggedIn && _userRepos == null && !_isLoadingUserRepos) {
    WidgetsBinding.instance.addPostFrameCallback((_) => _loadUserRepos());
    }
    // … 构建 UI
    }

    为什么使用 addPostFrameCallback?

    在 build 方法中直接调用 _loadTrending() 会触发 setState,而 setState 不能在 build 过程中调用。addPostFrameCallback 将回调延迟到当前帧渲染完成之后,此时调用 setState 是安全的。

    这三重条件判断(isLoggedIn && _trendingRepos == null && !_isLoadingTrending)确保:

    • 只在已登录时加载
    • 只有数据未加载时才触发
    • 只有没在加载中才触发(防止重复请求)

    热门仓库加载

    热门仓库通过搜索 API 获取,使用 stars:>100 作为质量过滤条件:

    Future<void> _loadTrending() async {
    if (_isLoadingTrending) return;

    setState(() {
    _isLoadingTrending = true;
    _error = null;
    });

    try {
    final apiClient = context.read<AtomGitApiClient>();
    final response = await apiClient.get(
    '/search/repositories',
    queryParams: {
    'q': 'stars:>100',
    'sort': 'stars',
    'order': 'desc',
    'per_page': '10',
    },
    );

    final items = parseList<dynamic>(response.data, 'items') ?? [];
    _trendingRepos = items
    .whereType<Map<String, dynamic>>()
    .map(Repository.fromJson)
    .toList();
    } on ApiException catch (e) {
    _error = e.message;
    } catch (e) {
    _error = '加载热门仓库失败';
    } finally {
    setState(() => _isLoadingTrending = false);
    }
    }

    搜索查询 stars:>100 表示只返回超过 100 颗星的仓库。排序方式 sort: 'stars' 配合 order: 'desc' 确保最热门的仓库排在最前面。每页 10 条足以填满首屏。

    用户仓库加载

    用户仓库使用不同的 API 端点和排序参数:

    Future<void> _loadUserRepos() async {
    if (_isLoadingUserRepos) return;

    setState(() => _isLoadingUserRepos = true);

    try {
    final apiClient = context.read<AtomGitApiClient>();
    final response = await apiClient.get(
    '/user/repos',
    queryParams: {
    'sort': 'updated',
    'per_page': '10',
    },
    );

    _userRepos = (parseList<dynamic>(response.data) ?? [])
    .whereType<Map<String, dynamic>>()
    .map(Repository.fromJson)
    .toList();
    } on ApiException catch (e) {
    // 用户仓库加载失败不阻塞热门仓库的展示
    // 只在两个都失败时才显示全局错误
    } finally {
    setState(() => _isLoadingUserRepos = false);
    }
    }

    注意错误处理的不同:用户仓库加载失败时没有设置 _error。这是因为热门仓库可以独立展示。只有当两个数据源都失败、用户看不到任何内容时,才需要展示全局错误状态。

    下拉刷新机制

    下拉刷新使用 Material 3 的 RefreshIndicator 组件包裹整个 body:

    Future<void> _onRefresh() async {
    final isLoggedIn = context.read<AuthProvider>().isLoggedIn;
    if (!isLoggedIn) return;

    // 清空缓存,强制重新加载
    setState(() {
    _trendingRepos = null;
    _userRepos = null;
    });

    await Future.wait([
    _loadTrending(),
    _loadUserRepos(),
    ]);
    }

    刷新流程分为三步:

  • 清空当前数据:将两个列表设为 null,UI 会立即展示 loading 状态
  • 并行加载:使用 Future.wait 同时发起两个请求,减少总等待时间
  • 自动恢复:加载完成后 setState 触发重建,RefreshIndicator 自动收起刷新动画
  • Future.wait 在这里比串行 await 优越 —— 两次 API 调用合计延迟可能从 600ms 降低到 300ms。

    但 Future.wait 有一个需要注意的行为:如果其中任意一个 Future 抛出异常,Future.wait 也会抛出异常。当前代码中 _loadUserRepos 内部已 catch 异常,所以 Future.wait 不会因为用户仓库加载失败而中断热门仓库的加载。

    未登录欢迎页

    欢迎页是访客看到的第一个界面,需要简洁但有吸引力:

    Widget _buildWelcome(BuildContext context) {
    return ListView(
    children: [
    const SizedBox(height: 60),
    Icon(
    Icons.code,
    size: 80,
    color: Theme.of(context).colorScheme.primary.withOpacity(0.6),
    ),
    const SizedBox(height: 24),
    Text(
    '探索 AtomGit',
    textAlign: TextAlign.center,
    style: Theme.of(context).textTheme.headlineMedium?.copyWith(
    fontWeight: FontWeight.bold,
    ),
    ),
    const SizedBox(height: 12),
    Padding(
    padding: const EdgeInsets.symmetric(horizontal: 40),
    child: Text(
    '发现优秀开源项目,参与社区协作,管理你的代码仓库',
    textAlign: TextAlign.center,
    style: Theme.of(context).textTheme.bodyLarge?.copyWith(
    color: Colors.grey,
    ),
    ),
    ),
    const SizedBox(height: 32),
    Padding(
    padding: const EdgeInsets.symmetric(horizontal: 32),
    child: FilledButton.icon(
    onPressed: () =>
    Navigator.pushNamed(context, '/login'),
    icon: const Icon(Icons.login),
    label: const Text('登录 / 注册'),
    ),
    ),
    const SizedBox(height: 24),
    // 未登录状态下的搜索入口
    Padding(
    padding: const EdgeInsets.symmetric(horizontal: 16),
    child: TextField(
    decoration: InputDecoration(
    hintText: '搜索仓库…',
    prefixIcon: const Icon(Icons.search),
    // InputDecoration 的样式在 AppTheme 中全局设置:
    // border: OutlineInputBorder(borderRadius: 12)
    // contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12)
    ),
    onSubmitted: (value) {
    if (value.trim().isNotEmpty) {
    Navigator.pushNamed(context, '/search',
    arguments: value.trim());
    }
    },
    ),
    ),
    ],
    );
    }

    欢迎页的布局层次:

  • 大图标(带透明度):建立视觉焦点
  • 标题文案:传达应用价值
  • 描述文案:解释能做什么
  • 主导按钮:引导登录(最优先操作)
  • 搜索框:提供有限的看功能(即使不登录也能搜索)
  • 搜索功能的权限控制是分层的:未登录用户可以搜索和浏览结果,但一些需要认证的操作(如 Star 仓库)会引导登录。

    已登录首页布局

    已登录用户的首页将两个数据源合并到一个 ListView 中:

    Widget _buildAuthedBody() {
    if (_error != null && _trendingRepos == null && _userRepos == null) {
    return ErrorRetryWidget(
    message: _error!,
    onRetry: _onRefresh,
    );
    }

    if (_isLoadingTrending && _trendingRepos == null) {
    return const LoadingIndicator(message: '加载中…');
    }

    final hasTrending = _trendingRepos != null && _trendingRepos!.isNotEmpty;
    final hasUserRepos = _userRepos != null && _userRepos!.isNotEmpty;

    if (!hasTrending && !hasUserRepos && !_isLoadingTrending) {
    return const Center(child: Text('暂无仓库'));
    }

    return ListView(
    children: [
    // 用户仓库区域
    if (hasUserRepos) ...[
    _SectionHeader(title: '我的仓库'),
    ..._userRepos!.map((repo) => _repoItem(repo)),
    const SizedBox(height: 16),
    ],

    // 热门仓库区域
    if (hasTrending) ...[
    _SectionHeader(title: '热门仓库'),
    ..._trendingRepos!.map((repo) => _repoItem(repo)),
    ],

    // 底部加载状态(用户仓库还在加载中)
    if (_isLoadingUserRepos && _userRepos == null)
    const Padding(
    padding: EdgeInsets.all(24),
    child: Center(child: CircularProgressIndicator()),
    ),
    ],
    );
    }

    状态优先级:

  • 全局错误(两个数据源都失败且无缓存)→ ErrorRetryWidget
  • 首次加载中(热门仓库数据尚未到)→ LoadingIndicator
  • 全部为空(两个数据源都返回空)→ 空状态提示
  • 正常展示(至少有一个数据源有数据)→ 列表
  • 分区标题组件

    class _SectionHeader extends StatelessWidget {
    final String title;

    const _SectionHeader({required this.title});


    Widget build(BuildContext context) {
    return Padding(
    padding: const EdgeInsets.fromLTRB(16, 20, 16, 8),
    child: Text(
    title,
    style: Theme.of(context).textTheme.titleMedium?.copyWith(
    fontWeight: FontWeight.w600,
    color: Theme.of(context).colorScheme.primary,
    ),
    ),
    );
    }
    }

    分区标题使用主题色,字体加粗,与仓库卡片形成视觉分区。

    RepoCard 组件的详细设计

    RepoCard 是首页使用最频繁的组件。每个仓库以卡片形式展示,包含多个信息层次:

    class RepoCard extends StatelessWidget {
    final Repository repo;
    final VoidCallback? onTap;

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


    Widget build(BuildContext context) {
    return Card(
    margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
    child: InkWell(
    onTap: onTap,
    borderRadius: BorderRadius.circular(12),
    child: Padding(
    padding: const EdgeInsets.all(16),
    child: Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
    // 第一层:仓库名 + 可见性图标
    _buildHeader(context),
    // 第二层:描述(可选,最多 2 行)
    if (repo.description != null && repo.description!.isNotEmpty) ...[
    const SizedBox(height: 8),
    _buildDescription(context),
    ],
    // 第三层:语言 + 统计 + 更新时间
    const SizedBox(height: 12),
    _buildStatsRow(context),
    ],
    ),
    ),
    ),
    );
    }
    }

    Card 与 InkWell 的配合

    Card 的 clipBehavior: Clip.antiAlias(在全局主题中设置)使圆角裁剪正常工作。InkWell 的 borderRadius 必须与 Card 的圆角一致(12px),否则水波纹效果会超出卡片范围,产生视觉瑕疵。

    可见性图标处理

    Widget _buildPrivacyIcon() {
    if (repo.isPrivate) {
    return Image.asset(
    'assets/images/private.png',
    width: 16,
    height: 16,
    errorBuilder: (context, error, stackTrace) =>
    const Icon(Icons.lock_outline, size: 16),
    );
    }
    return const Icon(Icons.book_outlined, size: 16);
    }

    优先使用项目内的私有图标资源,但通过 errorBuilder 提供了 Material 图标的 fallback。这个设计很小但很重要 —— 即使图标文件丢失或损坏,UI 不会因此崩溃或留白。

    统计行的布局技巧

    Widget _buildStatsRow(BuildContext context) {
    return Row(
    children: [
    // 语言指示点 + 语言名
    _LanguageDot(language: repo.language),
    if (repo.language != null) ...[
    const SizedBox(width: 4),
    Text(repo.language!,
    style: Theme.of(context).textTheme.bodySmall),
    const SizedBox(width: 16),
    ],
    // Stars
    Icon(Icons.star_border, size: 16,
    color: Theme.of(context).colorScheme.onSurfaceVariant),
    const SizedBox(width: 2),
    Text(_formatCount(repo.stargazersCount),
    style: Theme.of(context).textTheme.bodySmall),
    const SizedBox(width: 12),
    // Forks
    Icon(Icons.call_split, size: 16,
    color: Theme.of(context).colorScheme.onSurfaceVariant),
    const SizedBox(width: 2),
    Text(_formatCount(repo.forksCount),
    style: Theme.of(context).textTheme.bodySmall),
    // Spacer 将时间推到最右侧
    const Spacer(),
    Text(DateFormatter.relative(repo.updatedAt),
    style: Theme.of(context).textTheme.bodySmall),
    ],
    );
    }

    Spacer 在这里起着关键的排版作用 —— 它将左右两组信息分开,统计信息靠左,时间信息靠右。没有 Spacer 的话,时间会紧跟在 forks 数字后面,视觉效果混乱。

    语言颜色映射系统

    static final _languageColors = {
    'Dart': const Color(0xFF00B4AB),
    'Python': const Color(0xFF3572A5),
    'JavaScript': const Color(0xFFF7DF1E),
    'TypeScript': const Color(0xFF3178C6),
    'Java': const Color(0xFFB07219),
    'Go': const Color(0xFF00ADD8),
    'Rust': const Color(0xFFDEA584),
    'C++': const Color(0xFFF34B7D),
    'C': const Color(0xFF555555),
    'Swift': const Color(0xFFF05138),
    'Kotlin': const Color(0xFFA97BFF),
    };

    Widget _languageDot(String? language) {
    final color = _languageColors[language] ?? Colors.grey;
    return Container(
    width: 12,
    height: 12,
    decoration: BoxDecoration(
    color: color,
    shape: BoxShape.circle,
    ),
    );
    }

    这些颜色来自 GitHub 的语言色谱,确保用户看到熟悉的视觉暗示。未知语言统一使用灰色兜底。

    数字格式化

    String _formatCount(int count) {
    if (count >= 1000) {
    return '${(count / 1000).toStringAsFixed(1)}k';
    }
    return count.toString();
    }

    toStringAsFixed(1) 始终显示 1 位小数。这意味着 1000 显示为 “1.0k”,1200 显示为 “1.2k”。如果不需要 “.0” 后缀,可以改用:

    final result = count / 1000;
    if (result == result.roundToDouble()) {
    return '${result.toInt()}k';
    }
    return '${result.toStringAsFixed(1)}k';

    但当前简化版本在实际使用中表现良好,且代码更短。

    仓库导航

    点击仓库卡片后,需要解析仓库的 owner 和 name 参数,导航到详情页:

    Widget _repoItem(Repository repo) {
    final info = repo.ownerAndName;
    return RepoCard(
    repo: repo,
    onTap: info != null
    ? () {
    Navigator.pushNamed(
    context,
    '/repo',
    arguments: {
    'owner': info.owner,
    'name': info.name,
    },
    );
    }
    : null,
    );
    }

    onTap 为 null 时 InkWell 不展示水波纹效果,暗示用户该卡片不可点击。这种情况在数据异常时可能出现(fullName 无法解析出 owner 和 name)。

    ownerAndName 的两级解析

    ({String owner, String name})? get ownerAndName {
    // 优先:从 fullName 拆分(标准格式 "owner/name")
    final parts = fullName.split('/');
    if (parts.length == 2 &&
    parts[0].isNotEmpty &&
    parts[1].isNotEmpty) {
    return (owner: parts[0], name: parts[1]);
    }

    // Fallback:从 owner.login + path/name 组合
    final ownerLogin = owner?.login;
    final repoPath = path ?? name;
    if (ownerLogin != null &&
    ownerLogin.isNotEmpty &&
    repoPath.isNotEmpty) {
    return (owner: ownerLogin, name: repoPath);
    }

    return null;
    }

    这是整个应用中仓库导航的关键 getter。使用 Dart 3 Record 类型作为返回值,代码简洁且类型安全。

    错误处理的层次设计

    首页的错误处理遵循"逐步降级"原则:

  • 热门仓库加载失败 → 不阻塞用户仓库展示
  • 用户仓库加载失败 → 不阻塞热门仓库展示
  • 两者都失败 → 展示 ErrorRetryWidget
  • // 错误展示条件
    if (_error != null &&
    _trendingRepos == null &&
    _userRepos == null) {
    return ErrorRetryWidget(
    message: _error!,
    onRetry: _onRefresh,
    );
    }

    关键在于 && 条件 —— 只要有一个数据源有结果,就不展示错误。用户能看到部分内容总比看到错误页面好。

    加载状态的用户体验

    首页的加载过程分为三个阶段,每个阶段对应不同的 UI:

    阶段UI 状态用户感受
    首次加载 LoadingIndicator + “加载中…” 等待
    部分加载 热门已展示,用户仓库还在加载 内容逐渐出现
    刷新 RefreshIndicator 动画 下拉即可更新

    部分加载是提升感知性能的关键。热门仓库通常在 200ms 内返回(因为是公开数据),用户仓库可能稍慢。但用户先看到热门仓库,等待感会大幅降低。

    赞(0)
    未经允许不得转载:171主机测评 » AtomGit Flutter鸿蒙客户端:首页与仓库列表
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址