欢迎大家加入[开源鸿蒙跨平台开发者社区](https://openharmonycrossplatform.csdn.net),一起共建开源鸿蒙跨平台生态。

目录

1. 页面架构总览(上下两大区域)

2. 依赖(Markdown + 代码高亮)

3. Model 层

models/repo_tree_node.dart

models/readme.dart

4. API Service 层

services/gitcode_api_service.dart

5. Providers / ViewModel 层(Riverpod)

providers/repo_detail_providers.dart

6. 目录树可视化(递归渲染)

widgets/repo_tree_view.dart

7. README 原生 Markdown 渲染

widgets/readme_view.dart

8. 文件内容页 + 代码高亮(加分项)

pages/file_content_page.dart

9. 仓库详情页(核心页面)

pages/repo_detail_page.dart

10. 使用示例(对照任务给的仓库


1. 页面架构总览(上下两大区域)

RepoDetailPage

  • 上部区域:目录树 / 文件列表

    • 左侧树 or 纵向树形列表(递归渲染)

    • 支持点击文件夹展开/收起、进入下一级

  • 下部主区域:README.md Markdown 原生渲染

    • 拉取 README 内容

    • markdown -> 原生 widget(非网页)

数据完全通过 GitCode OpenAPI 获取、JSON 解析、Flutter Widget 渲染。
目录树接口:GET /api/v5/repos/{owner}/{repo}/git/trees/{sha}?recursive=1 (GitCode)
文件/目录内容接口:GET /api/v5/repos/{owner}/{repo}/contents/{path} (GitCode)
文件 Blob 接口:GET /api/v5/repos/{owner}/{repo}/git/blobs/{sha}(base64 content) (GitCode)


2. 依赖(Markdown + 代码高亮)

pubspec.yaml 增加:

dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.5.1
  dio: ^5.7.0
  flutter_markdown: ^0.7.3
  flutter_highlight: ^0.7.0
  highlight: ^0.7.0
  • flutter_markdown:Markdown 原生渲染(README)

  • flutter_highlight + highlight:代码语法高亮(加分项)


3. Model 层

models/repo_tree_node.dart

class RepoTreeNode {
  final String path;      // like "docs/README.md"
  final String type;      // "tree" or "blob"
  final String sha;
  final int? size;

  RepoTreeNode({
    required this.path,
    required this.type,
    required this.sha,
    this.size,
  });

  String get name => path.split('/').last;
  bool get isDir => type == 'tree';
  bool get isFile => type == 'blob';

  factory RepoTreeNode.fromJson(Map<String, dynamic> json) {
    return RepoTreeNode(
      path: json['path'] ?? '',
      type: json['type'] ?? '',
      sha: json['sha'] ?? '',
      size: json['size'],
    );
  }
}

models/readme.dart

class ReadmeData {
  final String content; // decoded markdown text
  final String sha;

  ReadmeData({required this.content, required this.sha});
}

4. API Service 层

services/gitcode_api_service.dart

import 'dart:convert';
import 'package:dio/dio.dart';
import '../models/repo_tree_node.dart';
import '../models/readme.dart';

class GitCodeApiService {
  final Dio _dio;

  GitCodeApiService({
    Dio? dio,
    String? accessToken,
  }) : _dio = dio ??
            Dio(BaseOptions(
              baseUrl: 'https://api.gitcode.com/api/v5',
              connectTimeout: const Duration(seconds: 10),
              receiveTimeout: const Duration(seconds: 10),
              headers: accessToken == null
                  ? {}
                  : {'Authorization': 'Bearer $accessToken'},
            ));

  /// 1) 获取目录树(支持 recursive=1)
  Future<List<RepoTreeNode>> fetchRepoTree({
    required String owner,
    required String repo,
    required String sha, // branch name like "master" or "main"
    bool recursive = true,
    String? filePath,
  }) async {
    final resp = await _dio.get(
      '/repos/$owner/$repo/git/trees/$sha',
      queryParameters: {
        'recursive': recursive ? 1 : 0,
        if (filePath != null) 'file_path': filePath,
      },
    );
    final tree = (resp.data['tree'] as List?) ?? [];
    return tree.map((e) => RepoTreeNode.fromJson(e)).toList();
  }

  /// 2) 获取某路径内容(目录或文件信息)
  Future<dynamic> fetchContents({
    required String owner,
    required String repo,
    required String path, // "" for root
    String? ref,          // branch
  }) async {
    final resp = await _dio.get(
      '/repos/$owner/$repo/contents/$path',
      queryParameters: {if (ref != null) 'ref': ref},
    );
    return resp.data;
  }

  /// 3) 通过 blob sha 拉文件内容(base64)
  Future<String> fetchBlobText({
    required String owner,
    required String repo,
    required String sha,
  }) async {
    final resp = await _dio.get('/repos/$owner/$repo/git/blobs/$sha');
    final contentBase64 = resp.data['content'] ?? '';
    final bytes = base64.decode(contentBase64);
    return utf8.decode(bytes);
  }

  /// 4) 获取 README(优先 README.md,其次 readme 等)
  Future<ReadmeData?> fetchReadme({
    required String owner,
    required String repo,
    String ref = 'master',
  }) async {
    // 直接走 contents 接口拿 README.md 的 sha
    final candidates = ['README.md', 'readme.md', 'README.MD'];
    for (final name in candidates) {
      try {
        final data = await fetchContents(
          owner: owner,
          repo: repo,
          path: name,
          ref: ref,
        );
        if (data is Map && data['sha'] != null) {
          final text = await fetchBlobText(
            owner: owner,
            repo: repo,
            sha: data['sha'],
          );
          return ReadmeData(content: text, sha: data['sha']);
        }
      } catch (_) {
        // try next
      }
    }
    return null;
  }
}

5. Providers / ViewModel 层(Riverpod)

providers/repo_detail_providers.dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../services/gitcode_api_service.dart';
import '../models/repo_tree_node.dart';
import '../models/readme.dart';

final gitCodeApiProvider = Provider<GitCodeApiService>((ref) {
  return GitCodeApiService(
    accessToken: null, // 公共仓库可不填;私有仓库填 token
  );
});

/// 当前目录路径(用于无刷新进入下一层)
final currentPathProvider = StateProvider<String>((ref) => '');

/// 目录树 Provider(一次拉全树 recursive=1)
final repoTreeProvider = FutureProvider.family<List<RepoTreeNode>, RepoId>(
  (ref, repoId) async {
    final api = ref.read(gitCodeApiProvider);
    return api.fetchRepoTree(
      owner: repoId.owner,
      repo: repoId.repo,
      sha: repoId.branch,
      recursive: true,
    );
  },
);

/// README Provider
final readmeProvider = FutureProvider.family<ReadmeData?, RepoId>(
  (ref, repoId) async {
    final api = ref.read(gitCodeApiProvider);
    return api.fetchReadme(
      owner: repoId.owner,
      repo: repoId.repo,
      ref: repoId.branch,
    );
  },
);

class RepoId {
  final String owner;
  final String repo;
  final String branch;
  const RepoId(this.owner, this.repo, {this.branch = 'master'});

  @override
  bool operator ==(Object other) =>
      other is RepoId &&
      owner == other.owner &&
      repo == other.repo &&
      branch == other.branch;

  @override
  int get hashCode => Object.hash(owner, repo, branch);
}

6. 目录树可视化(递归渲染)

做法:recursive=1 拉到全树,前端把 path 拆成节点树,然后递归 widget。

widgets/repo_tree_view.dart

import 'package:flutter/material.dart';
import '../models/repo_tree_node.dart';

class TreeItem {
  final String name;
  final String fullPath;
  final bool isDir;
  final String sha;
  final List<TreeItem> children;
  bool expanded;

  TreeItem({
    required this.name,
    required this.fullPath,
    required this.isDir,
    required this.sha,
    this.children = const [],
    this.expanded = false,
  });

  TreeItem copyWith({List<TreeItem>? children, bool? expanded}) => TreeItem(
        name: name,
        fullPath: fullPath,
        isDir: isDir,
        sha: sha,
        children: children ?? this.children,
        expanded: expanded ?? this.expanded,
      );
}

/// 把 flat tree 转成层级树
List<TreeItem> buildTree(List<RepoTreeNode> nodes) {
  final root = <String, TreeItem>{};

  for (final n in nodes) {
    final parts = n.path.split('/');
    Map<String, TreeItem> cur = root;
    String accPath = '';
    for (int i = 0; i < parts.length; i++) {
      final p = parts[i];
      accPath = accPath.isEmpty ? p : '$accPath/$p';
      final isLeaf = i == parts.length - 1;

      if (!cur.containsKey(accPath)) {
        cur[accPath] = TreeItem(
          name: p,
          fullPath: accPath,
          isDir: isLeaf ? n.isDir : true,
          sha: isLeaf ? n.sha : '',
          children: [],
        );
      }

      if (!isLeaf) {
        // dive
        final item = cur[accPath]!;
        final childMap = {
          for (final c in item.children) c.fullPath: c
        };
        cur = childMap;
        // re-attach later by rebuild
      }
    }
  }

  // 上面写法简化过头了,重新做 robust tree:
  final map = <String, TreeItem>{};
  for (final n in nodes) {
    final parts = n.path.split('/');
    String parentPath = '';
    for (int i = 0; i < parts.length; i++) {
      final part = parts[i];
      final currentPath =
          parentPath.isEmpty ? part : '$parentPath/$part';
      final isLeaf = i == parts.length - 1;

      map.putIfAbsent(
        currentPath,
        () => TreeItem(
          name: part,
          fullPath: currentPath,
          isDir: isLeaf ? n.isDir : true,
          sha: isLeaf ? n.sha : '',
          children: [],
        ),
      );
      parentPath = currentPath;
    }
  }

  // attach children
  for (final item in map.values) {
    final parentPath =
        item.fullPath.contains('/') ? item.fullPath.substring(0, item.fullPath.lastIndexOf('/')) : '';
    if (parentPath.isNotEmpty && map.containsKey(parentPath)) {
      final parent = map[parentPath]!;
      parent.children.add(item);
    }
  }

  // roots
  final roots = map.values.where((i) => !i.fullPath.contains('/')).toList();
  // sort
  void sortRec(List<TreeItem> list) {
    list.sort((a, b) {
      if (a.isDir != b.isDir) return a.isDir ? -1 : 1;
      return a.name.compareTo(b.name);
    });
    for (final i in list) sortRec(i.children);
  }

  sortRec(roots);
  return roots;
}

class RepoTreeView extends StatefulWidget {
  final List<TreeItem> roots;
  final void Function(TreeItem item) onTapDir;
  final void Function(TreeItem item) onTapFile;

  const RepoTreeView({
    super.key,
    required this.roots,
    required this.onTapDir,
    required this.onTapFile,
  });

  @override
  State<RepoTreeView> createState() => _RepoTreeViewState();
}

class _RepoTreeViewState extends State<RepoTreeView> {
  Widget _buildNode(TreeItem item, int depth) {
    final icon = item.isDir
        ? (item.expanded ? Icons.folder_open : Icons.folder)
        : Icons.insert_drive_file;

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        InkWell(
          onTap: () {
            setState(() {
              if (item.isDir) {
                item.expanded = !item.expanded;
                widget.onTapDir(item);
              } else {
                widget.onTapFile(item);
              }
            });
          },
          child: Padding(
            padding: EdgeInsets.only(left: depth * 12.0, top: 6, bottom: 6),
            child: Row(
              children: [
                Icon(icon, size: 18),
                const SizedBox(width: 6),
                Expanded(child: Text(item.name, maxLines: 1, overflow: TextOverflow.ellipsis)),
              ],
            ),
          ),
        ),
        if (item.isDir && item.expanded)
          ...item.children.map((c) => _buildNode(c, depth + 1)),
      ],
    );
  }

  @override
  Widget build(BuildContext context) {
    return ListView(
      children: widget.roots.map((r) => _buildNode(r, 0)).toList(),
    );
  }
}

7. README 原生 Markdown 渲染

widgets/readme_view.dart

import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';

class ReadmeView extends StatelessWidget {
  final String markdown;
  const ReadmeView({super.key, required this.markdown});

  @override
  Widget build(BuildContext context) {
    return Markdown(
      data: markdown,
      selectable: true,
      styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith(
        p: Theme.of(context).textTheme.bodyMedium,
        codeblockDecoration: BoxDecoration(
          color: Colors.black.withOpacity(0.05),
          borderRadius: BorderRadius.circular(8),
        ),
      ),
    );
  }
}

8. 文件内容页 + 代码高亮(加分项)

pages/file_content_page.dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_highlight/flutter_highlight.dart';
import 'package:highlight/languages/all.dart';
import '../providers/repo_detail_providers.dart';

class FileContentPage extends ConsumerWidget {
  final RepoId repoId;
  final String path; // file full path
  final String sha;

  const FileContentPage({
    super.key,
    required this.repoId,
    required this.path,
    required this.sha,
  });

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final api = ref.read(gitCodeApiProvider);

    return Scaffold(
      appBar: AppBar(title: Text(path.split('/').last)),
      body: FutureBuilder<String>(
        future: api.fetchBlobText(
          owner: repoId.owner,
          repo: repoId.repo,
          sha: sha,
        ),
        builder: (context, snap) {
          if (!snap.hasData) {
            return const Center(child: CircularProgressIndicator());
          }
          final text = snap.data!;
          final lang = _guessLanguage(path);

          return SingleChildScrollView(
            padding: const EdgeInsets.all(12),
            child: HighlightView(
              text,
              language: lang,
              languages: allLanguages,
              padding: const EdgeInsets.all(12),
              textStyle: const TextStyle(
                fontFamily: 'monospace',
                fontSize: 13,
              ),
            ),
          );
        },
      ),
    );
  }

  String? _guessLanguage(String path) {
    final ext = path.split('.').last.toLowerCase();
    const map = {
      'dart': 'dart',
      'js': 'javascript',
      'ts': 'typescript',
      'java': 'java',
      'kt': 'kotlin',
      'c': 'c',
      'cc': 'cpp',
      'cpp': 'cpp',
      'h': 'cpp',
      'py': 'python',
      'md': 'markdown',
      'json': 'json',
      'yaml': 'yaml',
      'yml': 'yaml',
      'xml': 'xml',
      'sh': 'bash',
    };
    return map[ext];
  }
}

9. 仓库详情页(核心页面)

pages/repo_detail_page.dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/repo_detail_providers.dart';
import '../widgets/repo_tree_view.dart';
import '../widgets/readme_view.dart';
import 'file_content_page.dart';

class RepoDetailPage extends ConsumerWidget {
  final RepoId repoId;
  const RepoDetailPage({super.key, required this.repoId});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final treeAsync = ref.watch(repoTreeProvider(repoId));
    final readmeAsync = ref.watch(readmeProvider(repoId));

    return Scaffold(
      appBar: AppBar(title: Text('${repoId.owner}/${repoId.repo}')),
      body: treeAsync.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (e, st) => _ErrorView(msg: e.toString(), onRetry: () {
          ref.invalidate(repoTreeProvider(repoId));
          ref.invalidate(readmeProvider(repoId));
        }),
        data: (nodes) {
          final roots = buildTree(nodes);

          return Column(
            children: [
              // 上部:目录树
              SizedBox(
                height: MediaQuery.of(context).size.height * 0.45,
                child: RepoTreeView(
                  roots: roots,
                  onTapDir: (item) {
                    // 点击目录:只展开/收起,不跳页;下方 README 不变
                  },
                  onTapFile: (item) {
                    Navigator.push(
                      context,
                      MaterialPageRoute(
                        builder: (_) => FileContentPage(
                          repoId: repoId,
                          path: item.fullPath,
                          sha: item.sha,
                        ),
                      ),
                    );
                  },
                ),
              ),

              const Divider(height: 1),

              // 下部:README
              Expanded(
                child: readmeAsync.when(
                  loading: () => const Center(child: CircularProgressIndicator()),
                  error: (e, st) => Center(
                    child: Text('README 加载失败:$e'),
                  ),
                  data: (readme) {
                    if (readme == null || readme.content.trim().isEmpty) {
                      return const Center(child: Text('该仓库没有 README'));
                    }
                    return ReadmeView(markdown: readme.content);
                  },
                ),
              ),
            ],
          );
        },
      ),
    );
  }
}

class _ErrorView extends StatelessWidget {
  final String msg;
  final VoidCallback onRetry;
  const _ErrorView({required this.msg, required this.onRetry});

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(mainAxisSize: MainAxisSize.min, children: [
        Text('加载失败:$msg'),
        const SizedBox(height: 8),
        ElevatedButton(onPressed: onRetry, child: const Text('重试')),
      ]),
    );
  }
}

10. 使用示例(对照任务给的仓库)

比如你要展示 https://gitcode.com/openharmony/docs

Navigator.push(
  context,
  MaterialPageRoute(
    builder: (_) => RepoDetailPage(
      repoId: const RepoId('openharmony', 'docs', branch: 'master'),
    ),
  ),
);

页面效果与你描述一致:
上层显示目录树+文件,点目录无刷新展开,点文件进入内容页;下层显示 README。


11. 可以继续优化的点

  1. 局部加载而非全树
    当前用 recursive=1 一次拉全树(简单、快完成)。
    进阶:点击目录时按 file_path 再拉局部 Tree,减少大仓库流量。(GitCode)

  2. 缓存(参考 CSDN 的 24h)

    • RepoTreeREADME 都可落 SharedPreferences

    • 过期策略 24h

    • 缓存优先 / 后台刷新(和你学的 Day11 一致)

  3. 目录树 UI 体验

    • 当前是纵向递归树

    • 可加面包屑导航(currentPathProvider)

    • 文件/文件夹图标更贴近 GitCode 网页

  4. Markdown 样式

    • 支持图片/链接/表格

    • 自定义 codeblock widget(结合 Highlight 做 markdown 内高亮)


总结

本文介绍了一个基于Flutter实现的GitCode仓库详情页应用架构。系统采用上下分区的页面布局,上部显示递归渲染的目录树结构,下部展示Markdown格式的README内容。技术实现上,通过GitCode OpenAPI获取数据,使用Riverpod进行状态管理,包含API服务层、模型层、视图层等完整架构。关键功能包括:目录树可视化(支持递归展开/收起)、README原生渲染、文件内容查看(支持代码高亮)等。项目依赖flutter_markdown和highlight等库实现核心功能,并提供了可扩展的优

Logo

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

更多推荐