在这里插入图片描述

组件库概览

项目中提取了 6 个可复用组件,位于 lib/shared/widgets/lib/features/repo/widgets/

组件 路径 用途
ErrorRetryWidget shared/widgets/ 错误状态 + 重试
LoadingIndicator shared/widgets/ 加载中状态
MarkdownViewer shared/widgets/ Markdown 渲染
PaginatedList shared/widgets/ 无限滚动列表
UserAvatar shared/widgets/ 用户头像
RepoCard repo/widgets/ 仓库卡片

ErrorRetryWidget

class ErrorRetryWidget extends StatelessWidget {
  final String message;
  final VoidCallback? onRetry;

  const ErrorRetryWidget({
    super.key,
    required this.message,
    this.onRetry,
  });

  
  Widget build(BuildContext context) {
    return Center(
      child: Padding(
        padding: const EdgeInsets.all(32),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.error_outline, size: 64,
                color: Theme.of(context).colorScheme.error),
            const SizedBox(height: 16),
            Text(message,
                textAlign: TextAlign.center,
                style: Theme.of(context).textTheme.bodyLarge),
            if (onRetry != null) ...[
              const SizedBox(height: 24),
              FilledButton.icon(
                onPressed: onRetry,
                icon: const Icon(Icons.refresh),
                label: const Text('重试'),
              ),
            ],
          ],
        ),
      ),
    );
  }
}

onRetry 为可空参数 —— 提供时展示重试按钮,不提供时只展示错误信息。错误图标使用主题色 colorScheme.error

典型用法:

if (provider.error != null && provider.repositories.isEmpty) {
  return ErrorRetryWidget(
    message: provider.error!,
    onRetry: () => provider.load(),
  );
}

LoadingIndicator

class LoadingIndicator extends StatelessWidget {
  final String? message;

  const LoadingIndicator({super.key, this.message});

  
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          const CircularProgressIndicator(),
          if (message != null) ...[
            const SizedBox(height: 16),
            Text(message!,
                style: Theme.of(context).textTheme.bodyMedium),
          ],
        ],
      ),
    );
  }
}

简洁的居中加载指示器,可选文案。

MarkdownViewer

class MarkdownViewer extends StatelessWidget {
  final String markdown;

  const MarkdownViewer({super.key, required this.markdown});

  
  Widget build(BuildContext context) {
    return MarkdownBody(
      data: markdown,
      styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context))
          .copyWith(
            h1: Theme.of(context).textTheme.headlineSmall,
            h2: Theme.of(context).textTheme.titleLarge,
            h3: Theme.of(context).textTheme.titleMedium,
            p: Theme.of(context).textTheme.bodyMedium,
            code: TextStyle(
              fontFamily: 'monospace',
              fontSize: 13,
              backgroundColor: Theme.of(context)
                  .colorScheme
                  .surfaceContainerHighest,
            ),
            codeblockDecoration: BoxDecoration(
              color: Theme.of(context)
                  .colorScheme
                  .surfaceContainerHighest,
              borderRadius: BorderRadius.circular(8),
            ),
          ),
    );
  }
}

包装 flutter_markdownMarkdownBody,通过 MarkdownStyleSheet.fromTheme() 继承当前主题,再覆盖代码块和内联代码的样式。代码使用等宽字体和圆角背景容器。

PaginatedList

class PaginatedList extends StatelessWidget {
  final int itemCount;
  final Widget Function(BuildContext, int) itemBuilder;
  final VoidCallback? onLoadMore;
  final bool isLoading;
  final bool hasMore;
  final String emptyMessage;

  
  Widget build(BuildContext context) {
    if (itemCount == 0 && !isLoading) {
      return Center(child: Text(emptyMessage));
    }

    return ListView.builder(
      addAutomaticKeepAlives: true,
      itemCount: itemCount + (hasMore ? 1 : 0),
      itemBuilder: (context, index) {
        if (index >= itemCount) {
          return const Padding(
            padding: EdgeInsets.all(16),
            child: Center(child: CircularProgressIndicator()),
          );
        }
        return itemBuilder(context, index);
      },
    );
  }
}

addAutomaticKeepAlives: true 保持列表项状态。当 hasMore 时在末尾追加加载指示器。空数据时展示 emptyMessage

UserAvatar

class UserAvatar extends StatelessWidget {
  final String? avatarUrl;
  final double size;
  final String? name;

  const UserAvatar({
    super.key,
    this.avatarUrl,
    this.size = 40,
    this.name,
  });

  
  Widget build(BuildContext context) {
    if (avatarUrl != null && avatarUrl!.isNotEmpty) {
      return CircleAvatar(
        radius: size / 2,
        backgroundImage: NetworkImage(avatarUrl!),
      );
    }

    return CircleAvatar(
      radius: size / 2,
      backgroundColor: Theme.of(context).colorScheme.primaryContainer,
      child: Text(
        (name?.isNotEmpty == true) ? name![0].toUpperCase() : '?',
        style: TextStyle(
          fontSize: size * 0.4,
          color: Theme.of(context).colorScheme.onPrimaryContainer,
        ),
      ),
    );
  }
}

有头像 URL 时加载网络图片,无 URL 时显示首字母大写或 ?。字体大小随 size 等比缩放。

RepoCard

最复杂的共享组件,包含完整的仓库信息展示:

class RepoCard extends StatelessWidget {
  final Repository repo;
  final VoidCallback? 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),
              if (repo.description != null) ...[
                const SizedBox(height: 8),
                _buildDescription(context),
              ],
              const SizedBox(height: 12),
              _buildStats(context),
            ],
          ),
        ),
      ),
    );
  }
}

头部:私有/公开图标 + 仓库名

Widget _buildHeader(BuildContext context) {
  return Row(children: [
    _buildPrivacyIcon(),
    const SizedBox(width: 8),
    Expanded(
      child: Text(repo.fullName,
          style: Theme.of(context).textTheme.titleMedium?.copyWith(
                fontWeight: FontWeight.w600,
              )),
    ),
  ]);
}

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

尝试加载私有仓库的本地图标,失败时 fallback 到 Material 图标。

描述(最多 2 行)

Widget _buildDescription(BuildContext context) {
  return Text(repo.description!,
      maxLines: 2,
      overflow: TextOverflow.ellipsis,
      style: Theme.of(context).textTheme.bodyMedium);
}

统计行

Widget _buildStats(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),
    ],
    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),
    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),
    const Spacer(),
    Text(DateFormatter.relative(repo.updatedAt),
        style: Theme.of(context).textTheme.bodySmall),
  ]);
}

语言颜色映射

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,
    ),
  );
}

11 种语言的 GitHub 风格配色,未知语言默认为灰色。

数量格式化

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

设计原则

  1. 无业务依赖 —— 除 RepoCard 外,其余组件都是纯 UI,不依赖任何业务 Provider
  2. 可空回调 —— onTaponRetrymessage 等都可空,按需提供
  3. 主题感知 —— 所有颜色通过 Theme.of(context) 获取,支持深色模式
  4. 最小状态 —— 全部使用 StatelessWidget,状态由父组件管理
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐