From 5d632e9561c09f795d831ec51ed1fea6f29debd2 Mon Sep 17 00:00:00 2001 From: Ayaka Hosoda Date: Mon, 8 Dec 2025 16:31:36 +0900 Subject: [PATCH 01/14] create contributor.dart --- lib/domain/contributor.dart | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 lib/domain/contributor.dart diff --git a/lib/domain/contributor.dart b/lib/domain/contributor.dart new file mode 100644 index 00000000..e69de29b From 6a9edae3ddc5959755a8d3df90b57bc40bf7dacf Mon Sep 17 00:00:00 2001 From: Ayaka Hosoda Date: Mon, 8 Dec 2025 17:27:38 +0900 Subject: [PATCH 02/14] Create GitHubProfile type --- lib/domain/contributor.dart | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lib/domain/contributor.dart b/lib/domain/contributor.dart index e69de29b..48072e5e 100644 --- a/lib/domain/contributor.dart +++ b/lib/domain/contributor.dart @@ -0,0 +1,13 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'contributor.freezed.dart'; + +@freezed +class GitHubProfile with _$GitHubProfile { + const factory GitHubProfile({ + required String id, + required String login, + required String avatarUrl, + required String htmlUrl, + }) = _GitHubProfile; +} From 1abd01a8a23c9e168bdfc05bcf4f3b4c0d05b1cb Mon Sep 17 00:00:00 2001 From: Ayaka Hosoda Date: Mon, 8 Dec 2025 17:36:51 +0900 Subject: [PATCH 03/14] Create GitHubProfile --- lib/domain/contributor.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/domain/contributor.dart b/lib/domain/contributor.dart index 48072e5e..c1d2d04c 100644 --- a/lib/domain/contributor.dart +++ b/lib/domain/contributor.dart @@ -3,7 +3,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'contributor.freezed.dart'; @freezed -class GitHubProfile with _$GitHubProfile { +abstract class GitHubProfile with _$GitHubProfile { const factory GitHubProfile({ required String id, required String login, From 7109ab7ad868070186fd2ebe3a3a6cd9b84db136 Mon Sep 17 00:00:00 2001 From: Ayaka Hosoda Date: Mon, 8 Dec 2025 17:51:44 +0900 Subject: [PATCH 04/14] Rename contributor.dart to github_profile.dart --- lib/domain/{contributor.dart => github_profile.dart} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename lib/domain/{contributor.dart => github_profile.dart} (100%) diff --git a/lib/domain/contributor.dart b/lib/domain/github_profile.dart similarity index 100% rename from lib/domain/contributor.dart rename to lib/domain/github_profile.dart From 19d8693167ba64a3e9def9dd4329204d4d269163 Mon Sep 17 00:00:00 2001 From: Ayaka Hosoda Date: Mon, 8 Dec 2025 17:53:49 +0900 Subject: [PATCH 05/14] Rename part file --- lib/domain/github_profile.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/domain/github_profile.dart b/lib/domain/github_profile.dart index c1d2d04c..078e5cd8 100644 --- a/lib/domain/github_profile.dart +++ b/lib/domain/github_profile.dart @@ -1,6 +1,6 @@ import 'package:freezed_annotation/freezed_annotation.dart'; -part 'contributor.freezed.dart'; +part 'github_profile.freezed.dart'; @freezed abstract class GitHubProfile with _$GitHubProfile { From 861fd296b801cb9515f0e0716baa0b5fd4e9b623 Mon Sep 17 00:00:00 2001 From: Ayaka Hosoda Date: Tue, 9 Dec 2025 13:03:32 +0900 Subject: [PATCH 06/14] Add fromJson --- lib/domain/github_profile.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/domain/github_profile.dart b/lib/domain/github_profile.dart index 078e5cd8..9d16510a 100644 --- a/lib/domain/github_profile.dart +++ b/lib/domain/github_profile.dart @@ -1,13 +1,18 @@ 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({ required String id, required String login, required String avatarUrl, required String htmlUrl, }) = _GitHubProfile; + + factory GitHubProfile.fromJson(Map json) => + _$GitHubProfileFromJson(json); } From 320cac90eae4a9d9b043e9c00c28a894e8854b20 Mon Sep 17 00:00:00 2001 From: Ayaka Hosoda Date: Tue, 9 Dec 2025 15:53:20 +0900 Subject: [PATCH 07/14] Create abstract class --- lib/repository/github_contoributor_repository.dart | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 lib/repository/github_contoributor_repository.dart diff --git a/lib/repository/github_contoributor_repository.dart b/lib/repository/github_contoributor_repository.dart new file mode 100644 index 00000000..5e13bd66 --- /dev/null +++ b/lib/repository/github_contoributor_repository.dart @@ -0,0 +1,5 @@ +import 'package:dotto/domain/github_profile.dart'; + +abstract class GitHubContributorRepository { + Future> getContributors(); +} From 9f070680016c743b1eb1852cc4930de124b816f6 Mon Sep 17 00:00:00 2001 From: Ayaka Hosoda Date: Tue, 9 Dec 2025 15:57:23 +0900 Subject: [PATCH 08/14] Create githubContributionRepositoryProvider --- lib/repository/github_contoributor_repository.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/repository/github_contoributor_repository.dart b/lib/repository/github_contoributor_repository.dart index 5e13bd66..ca0dccfb 100644 --- a/lib/repository/github_contoributor_repository.dart +++ b/lib/repository/github_contoributor_repository.dart @@ -1,4 +1,10 @@ import 'package:dotto/domain/github_profile.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final githubContributionRepositoryProvider = + Provider( + (ref) => GitHubContributorRepositoryImpl(ref), + ); abstract class GitHubContributorRepository { Future> getContributors(); From 75070053c6ee6aed6985e5e41bdc36092a6c9760 Mon Sep 17 00:00:00 2001 From: Ayaka Hosoda Date: Tue, 9 Dec 2025 16:31:56 +0900 Subject: [PATCH 09/14] Create GitHubContributorImpl --- .../github_contoributor_repository.dart | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/lib/repository/github_contoributor_repository.dart b/lib/repository/github_contoributor_repository.dart index ca0dccfb..6f2e2c09 100644 --- a/lib/repository/github_contoributor_repository.dart +++ b/lib/repository/github_contoributor_repository.dart @@ -1,4 +1,6 @@ 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 = @@ -9,3 +11,36 @@ final githubContributionRepositoryProvider = 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; + } + } +} From 4bd0e81986b89425ea6eea72a00cf6ffea1dc6ef Mon Sep 17 00:00:00 2001 From: Ayaka Hosoda Date: Fri, 12 Dec 2025 11:04:49 +0900 Subject: [PATCH 10/14] Create getContributors method --- .../github_contributor_service.dart | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 lib/feature/github_contributor/github_contributor_service.dart 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(); + } +} From 1c27adbe64df8b47acf58ff21554ed22efe3c920 Mon Sep 17 00:00:00 2001 From: Ayaka Hosoda Date: Fri, 12 Dec 2025 11:25:18 +0900 Subject: [PATCH 11/14] Create test --- .../github_contributor_service_test.dart | 98 +++++++++++++++++++ ...github_contributor_service_test.mocks.dart | 45 +++++++++ 2 files changed, 143 insertions(+) create mode 100644 test/feature/github_contributors/github_contributor_service_test.dart create mode 100644 test/feature/github_contributors/github_contributor_service_test.mocks.dart 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..e651c183 --- /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/announcement/announcement_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>); +} From c4015846258617bd78cb9e47c9ae018b6ef1bc42 Mon Sep 17 00:00:00 2001 From: Ayaka Hosoda Date: Fri, 12 Dec 2025 13:55:19 +0900 Subject: [PATCH 12/14] Create GitHubContributorViewState --- .../github_contributor_viewstate.dart | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 lib/feature/github_contributor/github_contributor_viewstate.dart 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; +} From b476cadfca64844484f310dc74a550f208a0f045 Mon Sep 17 00:00:00 2001 From: Ayaka Hosoda <167756153+Hosoda-abo@users.noreply.github.com> Date: Thu, 25 Dec 2025 20:33:29 +0900 Subject: [PATCH 13/14] =?UTF-8?q?GitHubContributorViewModel=E3=82=92?= =?UTF-8?q?=E4=BD=9C=E6=88=90=20(#414)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Create GitHubContributorVieeModel * Create githubContributorViewModelTest * Refactor GithubContributorViewmodel to use a class-level service instance for improved code clarity and efficiency (#417) --------- Co-authored-by: Kanta Oikawa --- .../github_contributor_viewmodel.dart | 24 +++ ...github_contributor_service_test.mocks.dart | 2 +- .../github_contributor_viewmodel_test.dart | 156 ++++++++++++++++++ ...thub_contributor_viewmodel_test.mocks.dart | 62 +++++++ 4 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 lib/feature/github_contributor/github_contributor_viewmodel.dart create mode 100644 test/feature/github_contributors/github_contributor_viewmodel_test.dart create mode 100644 test/feature/github_contributors/github_contributor_viewmodel_test.mocks.dart 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..042c9aad --- /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/test/feature/github_contributors/github_contributor_service_test.mocks.dart b/test/feature/github_contributors/github_contributor_service_test.mocks.dart index e651c183..4c76522a 100644 --- a/test/feature/github_contributors/github_contributor_service_test.mocks.dart +++ b/test/feature/github_contributors/github_contributor_service_test.mocks.dart @@ -1,5 +1,5 @@ // Mocks generated by Mockito 5.4.6 from annotations -// in dotto/test/feature/announcement/announcement_service_test.dart. +// 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 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..daf00aba --- /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, + ); +} From 163063a2618798aaccbd28d29d05d3faf3860e31 Mon Sep 17 00:00:00 2001 From: Ayaka Hosoda <167756153+Hosoda-abo@users.noreply.github.com> Date: Thu, 25 Dec 2025 20:40:51 +0900 Subject: [PATCH 14/14] =?UTF-8?q?GitHubContributorScreen=E3=82=92=E4=BD=9C?= =?UTF-8?q?=E6=88=90=20(#415)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Create GitHubContributorVieeModel * Create githubContributorViewModelTest * Create githubContributorScreen * Fix Copilot error * Transition from the Settings Screen to the GitHub Contributor Screen * Fix copilot error * Fix the classname GitHubContributorViewModelProvider * Display usename * Update lib/feature/setting/settings.dart Co-authored-by: Kanta Oikawa * Refactor GithubContributorViewmodel to use a class-level service instance for improved code clarity and efficiency (#417) --------- Co-authored-by: Kanta Oikawa --- lib/domain/github_profile.dart | 4 +- .../github_contributor_screen.dart | 65 +++++++++++++++++++ .../github_contributor_viewmodel.dart | 2 +- lib/feature/setting/settings.dart | 16 +++++ .../github_contributor_viewmodel_test.dart | 18 ++--- 5 files changed, 94 insertions(+), 11 deletions(-) create mode 100644 lib/feature/github_contributor/github_contributor_screen.dart diff --git a/lib/domain/github_profile.dart b/lib/domain/github_profile.dart index 9d16510a..bbd0211e 100644 --- a/lib/domain/github_profile.dart +++ b/lib/domain/github_profile.dart @@ -7,7 +7,7 @@ part 'github_profile.g.dart'; abstract class GitHubProfile with _$GitHubProfile { @JsonSerializable(fieldRename: FieldRename.snake) const factory GitHubProfile({ - required String id, + @JsonKey(fromJson: _idFromJson) required String id, required String login, required String avatarUrl, required String htmlUrl, @@ -16,3 +16,5 @@ abstract class GitHubProfile with _$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_viewmodel.dart b/lib/feature/github_contributor/github_contributor_viewmodel.dart index 042c9aad..605db248 100644 --- a/lib/feature/github_contributor/github_contributor_viewmodel.dart +++ b/lib/feature/github_contributor/github_contributor_viewmodel.dart @@ -5,7 +5,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'github_contributor_viewmodel.g.dart'; @riverpod -final class GithubContributorViewmodel extends _$GithubContributorViewmodel { +final class GitHubContributorViewModel extends _$GitHubContributorViewModel { late final GitHubContributorService _service; @override 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/test/feature/github_contributors/github_contributor_viewmodel_test.dart b/test/feature/github_contributors/github_contributor_viewmodel_test.dart index daf00aba..6adba95c 100644 --- a/test/feature/github_contributors/github_contributor_viewmodel_test.dart +++ b/test/feature/github_contributors/github_contributor_viewmodel_test.dart @@ -57,13 +57,13 @@ void main() { test('初期状態が正しく設定される', () async { final container = createContainer() ..listen( - githubContributorViewmodelProvider, + gitHubContributorViewModelProvider, listener.call, fireImmediately: true, ); await expectLater( - container.read(githubContributorViewmodelProvider.notifier).future, + container.read(gitHubContributorViewModelProvider.notifier).future, completion( isA().having( (p0) => p0.contributors, @@ -77,14 +77,14 @@ void main() { test('GitHubProfileが正しく取得される', () async { final container = createContainer() ..listen( - githubContributorViewmodelProvider, + gitHubContributorViewModelProvider, listener.call, fireImmediately: true, ); // 初期状態を待つ final initialState = await container - .read(githubContributorViewmodelProvider.notifier) + .read(gitHubContributorViewModelProvider.notifier) .future; expect(initialState.contributors, testGitHubContributors); @@ -106,14 +106,14 @@ void main() { final container = createContainer() ..listen( - githubContributorViewmodelProvider, + gitHubContributorViewModelProvider, listener.call, fireImmediately: true, ); // 初期状態を待つ final initialState = await container - .read(githubContributorViewmodelProvider.notifier) + .read(gitHubContributorViewModelProvider.notifier) .future; expect(initialState.contributors, isEmpty); @@ -133,17 +133,17 @@ void main() { test('GitHubProfileの取得に失敗した場合にエラーがthrowされる', () async { final container = createContainer() ..listen( - githubContributorViewmodelProvider, + gitHubContributorViewModelProvider, listener.call, fireImmediately: true, ); // AsyncValue がエラー状態になるまで待つ - var asyncValue = container.read(githubContributorViewmodelProvider); + var asyncValue = container.read(gitHubContributorViewModelProvider); var attempts = 0; while (!asyncValue.hasError && attempts < 100) { await Future.delayed(const Duration(milliseconds: 10)); - asyncValue = container.read(githubContributorViewmodelProvider); + asyncValue = container.read(gitHubContributorViewModelProvider); attempts++; }