diff --git a/lib/domain/github_profile.dart b/lib/domain/github_profile.dart new file mode 100644 index 00000000..bbd0211e --- /dev/null +++ b/lib/domain/github_profile.dart @@ -0,0 +1,20 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'github_profile.freezed.dart'; +part 'github_profile.g.dart'; + +@freezed +abstract class GitHubProfile with _$GitHubProfile { + @JsonSerializable(fieldRename: FieldRename.snake) + const factory GitHubProfile({ + @JsonKey(fromJson: _idFromJson) required String id, + required String login, + required String avatarUrl, + required String htmlUrl, + }) = _GitHubProfile; + + factory GitHubProfile.fromJson(Map json) => + _$GitHubProfileFromJson(json); +} + +String _idFromJson(Object? json) => json == null ? '' : json.toString(); diff --git a/lib/feature/github_contributor/github_contributor_screen.dart b/lib/feature/github_contributor/github_contributor_screen.dart new file mode 100644 index 00000000..970237c2 --- /dev/null +++ b/lib/feature/github_contributor/github_contributor_screen.dart @@ -0,0 +1,65 @@ +import 'package:dotto/domain/github_profile.dart'; +import 'package:dotto/feature/github_contributor/github_contributor_viewmodel.dart'; +import 'package:dotto/feature/github_contributor/github_contributor_viewstate.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +final class GitHubContributorScreen extends ConsumerWidget { + const GitHubContributorScreen({super.key}); + + Widget _githubContributorListRow(GitHubProfile githubProfile) { + return ListTile( + leading: GestureDetector( + child: CircleAvatar( + radius: 20, + backgroundImage: NetworkImage(githubProfile.avatarUrl), + backgroundColor: Colors.grey.shade200, + ), + ), + title: Text(githubProfile.login), + onTap: () => launchUrlString(githubProfile.htmlUrl), + ); + } + + Widget _body( + AsyncValue viewModelAsync, { + required Future Function() onRefresh, + }) { + switch (viewModelAsync) { + case AsyncData(:final value): + return RefreshIndicator( + onRefresh: onRefresh, + child: ListView.separated( + itemCount: value.contributors.length, + separatorBuilder: (_, _) => const Divider(height: 0), + itemBuilder: (_, index) { + final contributor = value.contributors[index]; + return _githubContributorListRow(contributor); + }, + ), + ); + case AsyncError(:final error): + return Center(child: Text('エラーが発生しました: $error')); + case AsyncLoading(): + return const Center(child: CircularProgressIndicator()); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final viewModelAsync = ref.watch(gitHubContributorViewModelProvider); + + return Scaffold( + appBar: AppBar(title: const Text('開発者一覧')), + body: _body( + viewModelAsync, + onRefresh: () async { + await ref + .read(gitHubContributorViewModelProvider.notifier) + .onRefresh(); + }, + ), + ); + } +} diff --git a/lib/feature/github_contributor/github_contributor_service.dart b/lib/feature/github_contributor/github_contributor_service.dart new file mode 100644 index 00000000..854c7edf --- /dev/null +++ b/lib/feature/github_contributor/github_contributor_service.dart @@ -0,0 +1,13 @@ +import 'package:dotto/domain/github_profile.dart'; +import 'package:dotto/repository/github_contoributor_repository.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final class GitHubContributorService { + GitHubContributorService(this.ref); + + final Ref ref; + + Future> getContributors() async { + return ref.read(githubContributionRepositoryProvider).getContributors(); + } +} diff --git a/lib/feature/github_contributor/github_contributor_viewmodel.dart b/lib/feature/github_contributor/github_contributor_viewmodel.dart new file mode 100644 index 00000000..605db248 --- /dev/null +++ b/lib/feature/github_contributor/github_contributor_viewmodel.dart @@ -0,0 +1,24 @@ +import 'package:dotto/feature/github_contributor/github_contributor_service.dart'; +import 'package:dotto/feature/github_contributor/github_contributor_viewstate.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'github_contributor_viewmodel.g.dart'; + +@riverpod +final class GitHubContributorViewModel extends _$GitHubContributorViewModel { + late final GitHubContributorService _service; + + @override + Future build() async { + _service = GitHubContributorService(ref); + final contributors = await _service.getContributors(); + return GitHubContributorViewState(contributors: contributors); + } + + Future onRefresh() async { + state = await AsyncValue.guard(() async { + final contributors = await _service.getContributors(); + return GitHubContributorViewState(contributors: contributors); + }); + } +} diff --git a/lib/feature/github_contributor/github_contributor_viewstate.dart b/lib/feature/github_contributor/github_contributor_viewstate.dart new file mode 100644 index 00000000..cd54aa72 --- /dev/null +++ b/lib/feature/github_contributor/github_contributor_viewstate.dart @@ -0,0 +1,11 @@ +import 'package:dotto/domain/github_profile.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'github_contributor_viewstate.freezed.dart'; + +@freezed +abstract class GitHubContributorViewState with _$GitHubContributorViewState { + const factory GitHubContributorViewState({ + required List contributors, + }) = _GitHubContributorViewState; +} diff --git a/lib/feature/setting/settings.dart b/lib/feature/setting/settings.dart index 96c79eb9..a45edc88 100644 --- a/lib/feature/setting/settings.dart +++ b/lib/feature/setting/settings.dart @@ -6,6 +6,7 @@ import 'package:dotto/domain/user_preference_keys.dart'; import 'package:dotto/feature/announcement/announcement_screen.dart'; import 'package:dotto/feature/assignment/setup_hope_continuity_screen.dart'; import 'package:dotto/feature/debug/debug_screen.dart'; +import 'package:dotto/feature/github_contributor/github_contributor_screen.dart'; import 'package:dotto/feature/setting/controller/settings_controller.dart'; import 'package:dotto/feature/setting/repository/settings_repository.dart'; import 'package:dotto/feature/setting/widget/license.dart'; @@ -209,6 +210,21 @@ final class SettingsScreen extends ConsumerWidget { launchUrlString(config.feedbackFormUrl); }, ), + // Contributors表示 + SettingsTile.navigation( + title: const Text('開発者'), + leading: const Icon(Icons.person), + onPressed: (_) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const GitHubContributorScreen(), + settings: const RouteSettings( + name: '/setting/github_contributors', + ), + ), + ); + }, + ), // アプリの使い方 SettingsTile.navigation( title: const Text('アプリの使い方'), diff --git a/lib/repository/github_contoributor_repository.dart b/lib/repository/github_contoributor_repository.dart new file mode 100644 index 00000000..6f2e2c09 --- /dev/null +++ b/lib/repository/github_contoributor_repository.dart @@ -0,0 +1,46 @@ +import 'package:dotto/domain/github_profile.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final githubContributionRepositoryProvider = + Provider( + (ref) => GitHubContributorRepositoryImpl(ref), + ); + +abstract class GitHubContributorRepository { + Future> getContributors(); +} + +final class GitHubContributorRepositoryImpl + implements GitHubContributorRepository { + GitHubContributorRepositoryImpl(this.ref); + + final Ref ref; + + @override + Future> getContributors() async { + try { + final dio = Dio(); + // GitHub contributors API for this repository + const url = 'https://api.github.com/repos/fun-dotto/dotto/contributors'; + + final response = await dio.get(url); + if (response.statusCode != 200) { + throw Exception('Failed to get contributors'); + } + + final data = response.data; + if (data == null || data is! List) { + throw Exception('Failed to get contributors'); + } + + return data + .map((e) => GitHubProfile.fromJson(e as Map)) + .toList(); + } catch (e) { + debugPrint(e.toString()); + rethrow; + } + } +} diff --git a/test/feature/github_contributors/github_contributor_service_test.dart b/test/feature/github_contributors/github_contributor_service_test.dart new file mode 100644 index 00000000..7cee343e --- /dev/null +++ b/test/feature/github_contributors/github_contributor_service_test.dart @@ -0,0 +1,98 @@ +import 'package:dotto/domain/github_profile.dart'; +import 'package:dotto/feature/github_contributor/github_contributor_service.dart'; +import 'package:dotto/repository/github_contoributor_repository.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'github_contributor_service_test.mocks.dart'; + +/// テスト用の GitHubContributorService Provider +final GitHubContributorServiceProvider = Provider( + GitHubContributorService.new, +); + +@GenerateMocks([GitHubContributorRepository]) +void main() { + final githubContributorRepository = MockGitHubContributorRepository(); + + final testGitHubProfiles = [ + GitHubProfile( + id: '1', + login: 'GitHubUser1', + avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4', + htmlUrl: 'https://github.com/GitHubUser1', + ), + GitHubProfile( + id: '2', + login: 'GitHubUser2', + avatarUrl: 'https://avatars.githubusercontent.com/u/2?v=4', + htmlUrl: 'https://github.com/GitHubUser2', + ), + ]; + + ProviderContainer createContainer() => ProviderContainer( + overrides: [ + githubContributionRepositoryProvider.overrideWithValue( + githubContributorRepository, + ), + ], + ); + + setUp(() { + reset(githubContributorRepository); + }); + + group('GitHubContributorService 正常系', () { + test('getContributors がGitHubプロフィール一覧を正しく取得する', () async { + when( + githubContributorRepository.getContributors(), + ).thenAnswer((_) async => testGitHubProfiles); + + final container = createContainer(); + final service = container.read(githubContributionRepositoryProvider); + + final result = await service.getContributors(); + + expect(result, testGitHubProfiles); + expect(result.length, 2); + expect(result[0].id, '1'); + expect(result[0].login, 'GitHubUser1'); + expect(result[1].id, '2'); + expect(result[1].login, 'GitHubUser2'); + + verify(githubContributorRepository.getContributors()).called(1); + }); + + test('getContributors が空のリストを正しく取得する', () async { + when( + githubContributorRepository.getContributors(), + ).thenAnswer((_) async => []); + + final container = createContainer(); + final service = container.read(GitHubContributorServiceProvider); + + final result = await service.getContributors(); + + expect(result, isEmpty); + + verify(githubContributorRepository.getContributors()).called(1); + }); + }); + + group('GitHubContributorService 異常系', () { + test('getContributors がリポジトリの例外をそのまま伝播する', () async { + when( + githubContributorRepository.getContributors(), + ).thenThrow(Exception('Failed to get contributors')); + + final container = createContainer(); + final service = container.read(GitHubContributorServiceProvider); + + expect(() => service.getContributors(), throwsA(isA())); + + verify(githubContributorRepository.getContributors()).called(1); + }); + }); +} diff --git a/test/feature/github_contributors/github_contributor_service_test.mocks.dart b/test/feature/github_contributors/github_contributor_service_test.mocks.dart new file mode 100644 index 00000000..4c76522a --- /dev/null +++ b/test/feature/github_contributors/github_contributor_service_test.mocks.dart @@ -0,0 +1,45 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in dotto/test/feature/github_contributors/github_contributor_service_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; + +import 'package:dotto/domain/github_profile.dart' as _i4; +import 'package:dotto/repository/github_contoributor_repository.dart' as _i2; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +/// A class which mocks [GitHubContributorRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockGitHubContributorRepository extends _i1.Mock + implements _i2.GitHubContributorRepository { + MockGitHubContributorRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future> getContributors() => + (super.noSuchMethod( + Invocation.method(#getContributors, []), + returnValue: _i3.Future>.value( + <_i4.GitHubProfile>[], + ), + ) + as _i3.Future>); +} diff --git a/test/feature/github_contributors/github_contributor_viewmodel_test.dart b/test/feature/github_contributors/github_contributor_viewmodel_test.dart new file mode 100644 index 00000000..6adba95c --- /dev/null +++ b/test/feature/github_contributors/github_contributor_viewmodel_test.dart @@ -0,0 +1,156 @@ +import 'package:dotto/domain/github_profile.dart'; +import 'package:dotto/feature/github_contributor/github_contributor_viewmodel.dart'; +import 'package:dotto/feature/github_contributor/github_contributor_viewstate.dart'; +import 'package:dotto/repository/github_contoributor_repository.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'github_contributor_viewmodel_test.mocks.dart'; + +abstract interface class Listener { + void call(T? previous, T next); +} + +@GenerateMocks([GitHubContributorRepository, Listener]) +void main() { + final githubContributorRepository = MockGitHubContributorRepository(); + final listener = MockListener>(); + + final testGitHubContributors = [ + GitHubProfile( + id: '1', + login: 'GitHubUser1', + avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4', + htmlUrl: 'https://github.com/GitHubUser1', + ), + GitHubProfile( + id: '2', + login: 'GitHubUser2', + avatarUrl: 'https://avatars.githubusercontent.com/u/2?v=4', + htmlUrl: 'https://github.com/GitHubUser2', + ), + ]; + + ProviderContainer createContainer() => ProviderContainer( + overrides: [ + githubContributionRepositoryProvider.overrideWithValue( + githubContributorRepository, + ), + ], + ); + + setUp(() { + reset(listener); + reset(githubContributorRepository); + }); + + group('GitHubContributorViewModel 正常系', () { + setUp(() { + when(githubContributorRepository.getContributors()).thenAnswer((_) async { + await Future.delayed(const Duration(milliseconds: 1)); + return testGitHubContributors; + }); + }); + + test('初期状態が正しく設定される', () async { + final container = createContainer() + ..listen( + gitHubContributorViewModelProvider, + listener.call, + fireImmediately: true, + ); + + await expectLater( + container.read(gitHubContributorViewModelProvider.notifier).future, + completion( + isA().having( + (p0) => p0.contributors, + 'contributors', + testGitHubContributors, + ), + ), + ); + }); + + test('GitHubProfileが正しく取得される', () async { + final container = createContainer() + ..listen( + gitHubContributorViewModelProvider, + listener.call, + fireImmediately: true, + ); + + // 初期状態を待つ + final initialState = await container + .read(gitHubContributorViewModelProvider.notifier) + .future; + + expect(initialState.contributors, testGitHubContributors); + expect(initialState.contributors.length, 2); + expect(initialState.contributors[0].id, '1'); + expect(initialState.contributors[0].login, 'GitHubUser1'); + expect(initialState.contributors[1].id, '2'); + expect(initialState.contributors[1].login, 'GitHubUser2'); + + // listener が呼ばれたことを確認 + verify(listener.call(any, any)).called(greaterThan(0)); + }); + + test('GitHubProfileが空の場合でも正しく動作する', () async { + when(githubContributorRepository.getContributors()).thenAnswer((_) async { + await Future.delayed(const Duration(milliseconds: 1)); + return []; + }); + + final container = createContainer() + ..listen( + gitHubContributorViewModelProvider, + listener.call, + fireImmediately: true, + ); + + // 初期状態を待つ + final initialState = await container + .read(gitHubContributorViewModelProvider.notifier) + .future; + + expect(initialState.contributors, isEmpty); + + // listener が呼ばれたことを確認 + verify(listener.call(any, any)).called(greaterThan(0)); + }); + }); + + group('GitHubContributorViewModel 異常系', () { + setUp(() { + when(githubContributorRepository.getContributors()).thenAnswer((_) async { + throw Exception('Failed to get contributors'); + }); + }); + + test('GitHubProfileの取得に失敗した場合にエラーがthrowされる', () async { + final container = createContainer() + ..listen( + gitHubContributorViewModelProvider, + listener.call, + fireImmediately: true, + ); + + // AsyncValue がエラー状態になるまで待つ + var asyncValue = container.read(gitHubContributorViewModelProvider); + var attempts = 0; + while (!asyncValue.hasError && attempts < 100) { + await Future.delayed(const Duration(milliseconds: 10)); + asyncValue = container.read(gitHubContributorViewModelProvider); + attempts++; + } + + // AsyncValue が AsyncError であることを確認 + expect(asyncValue.hasError, isTrue); + expect(asyncValue.error, isA()); + expect(() => asyncValue.requireValue, throwsA(isA())); + }); + }); +} diff --git a/test/feature/github_contributors/github_contributor_viewmodel_test.mocks.dart b/test/feature/github_contributors/github_contributor_viewmodel_test.mocks.dart new file mode 100644 index 00000000..bd9759b8 --- /dev/null +++ b/test/feature/github_contributors/github_contributor_viewmodel_test.mocks.dart @@ -0,0 +1,62 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in dotto/test/feature/github_contributors/github_contributor_viewmodel_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; + +import 'package:dotto/domain/github_profile.dart' as _i4; +import 'package:dotto/repository/github_contoributor_repository.dart' as _i2; +import 'package:mockito/mockito.dart' as _i1; + +import 'github_contributor_viewmodel_test.dart' as _i5; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +/// A class which mocks [GitHubContributorRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockGitHubContributorRepository extends _i1.Mock + implements _i2.GitHubContributorRepository { + MockGitHubContributorRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future> getContributors() => + (super.noSuchMethod( + Invocation.method(#getContributors, []), + returnValue: _i3.Future>.value( + <_i4.GitHubProfile>[], + ), + ) + as _i3.Future>); +} + +/// A class which mocks [Listener]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockListener extends _i1.Mock implements _i5.Listener { + MockListener() { + _i1.throwOnMissingStub(this); + } + + @override + void call(T? previous, T? next) => super.noSuchMethod( + Invocation.method(#call, [previous, next]), + returnValueForMissingStub: null, + ); +}