Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5d632e9
create contributor.dart
Hosoda-abo Dec 8, 2025
6a9edae
Create GitHubProfile type
Hosoda-abo Dec 8, 2025
1abd01a
Create GitHubProfile
Hosoda-abo Dec 8, 2025
7109ab7
Rename contributor.dart to github_profile.dart
Hosoda-abo Dec 8, 2025
19d8693
Rename part file
Hosoda-abo Dec 8, 2025
1904bd4
Merge pull request #399 from fun-dotto/issue/383-githubProfile
Hosoda-abo Dec 8, 2025
861fd29
Add fromJson
Hosoda-abo Dec 9, 2025
320cac9
Create abstract class
Hosoda-abo Dec 9, 2025
9f07068
Create githubContributionRepositoryProvider
Hosoda-abo Dec 9, 2025
7507005
Create GitHubContributorImpl
Hosoda-abo Dec 9, 2025
a2d8ae7
Merge remote-tracking branch 'origin/main' into issue/384-GitHubContr…
Hosoda-abo Dec 9, 2025
200be56
Merge remote-tracking branch 'origin/main' into issue/324-contributors
Hosoda-abo Dec 10, 2025
dc008b5
Merge branch 'issue/324-contributors' into issue/384-GitHubContributo…
Hosoda-abo Dec 10, 2025
90cdf50
Merge pull request #404 from fun-dotto/issue/384-GitHubContributorRep…
Hosoda-abo Dec 10, 2025
4bd0e81
Create getContributors method
Hosoda-abo Dec 12, 2025
1c27adb
Create test
Hosoda-abo Dec 12, 2025
c401584
Create GitHubContributorViewState
Hosoda-abo Dec 12, 2025
17a17c9
Merge pull request #408 from fun-dotto/issue/385-githubContributorSer…
Hosoda-abo Dec 15, 2025
88a2b70
Merge pull request #409 from fun-dotto/issue/386-githubContributorVie…
Hosoda-abo Dec 15, 2025
7c4d580
Merge remote-tracking branch 'origin/main' into issue/324-contributors
Hosoda-abo Dec 15, 2025
b476cad
GitHubContributorViewModelを作成 (#414)
Hosoda-abo Dec 25, 2025
163063a
GitHubContributorScreenを作成 (#415)
Hosoda-abo Dec 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions lib/domain/github_profile.dart
Original file line number Diff line number Diff line change
@@ -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<String, Object?> json) =>
_$GitHubProfileFromJson(json);
}

String _idFromJson(Object? json) => json == null ? '' : json.toString();
65 changes: 65 additions & 0 deletions lib/feature/github_contributor/github_contributor_screen.dart
Original file line number Diff line number Diff line change
@@ -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<GitHubContributorViewState> viewModelAsync, {
required Future<void> 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();
},
),
);
}
}
13 changes: 13 additions & 0 deletions lib/feature/github_contributor/github_contributor_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import 'package:dotto/domain/github_profile.dart';
import 'package:dotto/repository/github_contoributor_repository.dart';
Copy link

Copilot AI Dec 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The import path contains a typo: "contoributor" should be "contributor".

Suggested change
import 'package:dotto/repository/github_contoributor_repository.dart';
import 'package:dotto/repository/github_contributor_repository.dart';

Copilot uses AI. Check for mistakes.
import 'package:flutter_riverpod/flutter_riverpod.dart';

final class GitHubContributorService {
GitHubContributorService(this.ref);

final Ref ref;

Future<List<GitHubProfile>> getContributors() async {
return ref.read(githubContributionRepositoryProvider).getContributors();
}
}
24 changes: 24 additions & 0 deletions lib/feature/github_contributor/github_contributor_viewmodel.dart
Original file line number Diff line number Diff line change
@@ -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<GitHubContributorViewState> build() async {
_service = GitHubContributorService(ref);
final contributors = await _service.getContributors();
return GitHubContributorViewState(contributors: contributors);
}

Future<void> onRefresh() async {
state = await AsyncValue.guard(() async {
final contributors = await _service.getContributors();
return GitHubContributorViewState(contributors: contributors);
});
}
}
11 changes: 11 additions & 0 deletions lib/feature/github_contributor/github_contributor_viewstate.dart
Original file line number Diff line number Diff line change
@@ -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<GitHubProfile> contributors,
}) = _GitHubContributorViewState;
}
16 changes: 16 additions & 0 deletions lib/feature/setting/settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void>(
builder: (_) => const GitHubContributorScreen(),
settings: const RouteSettings(
name: '/setting/github_contributors',
),
),
);
},
),
// アプリの使い方
SettingsTile.navigation(
title: const Text('アプリの使い方'),
Expand Down
46 changes: 46 additions & 0 deletions lib/repository/github_contoributor_repository.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import 'package:dotto/domain/github_profile.dart';
Copy link

Copilot AI Dec 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filename contains a typo: "contoributor" should be "contributor". This affects the import path used throughout the codebase.

Copilot uses AI. Check for mistakes.
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

final githubContributionRepositoryProvider =
Provider<GitHubContributorRepository>(
(ref) => GitHubContributorRepositoryImpl(ref),
);

abstract class GitHubContributorRepository {
Future<List<GitHubProfile>> getContributors();
}

final class GitHubContributorRepositoryImpl
implements GitHubContributorRepository {
GitHubContributorRepositoryImpl(this.ref);

final Ref ref;

@override
Future<List<GitHubProfile>> getContributors() async {
try {
final dio = Dio();
Copy link

Copilot AI Dec 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A new Dio instance is created on every API call, which is inefficient and prevents proper configuration reuse (headers, timeouts, interceptors). The Dio instance should be injected as a dependency or created once and reused.

Copilot uses AI. Check for mistakes.
// 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');
Comment on lines +30 to +35
Copy link

Copilot AI Dec 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error messages for different failure scenarios are identical, making debugging difficult. Consider providing more specific error messages for different cases: status code != 200 vs invalid response data structure.

Suggested change
throw Exception('Failed to get contributors');
}
final data = response.data;
if (data == null || data is! List) {
throw Exception('Failed to get contributors');
throw Exception(
'Failed to get contributors: HTTP ${response.statusCode}',
);
}
final data = response.data;
if (data == null || data is! List) {
throw Exception('Failed to get contributors: invalid response format');

Copilot uses AI. Check for mistakes.
}

return data
.map((e) => GitHubProfile.fromJson(e as Map<String, dynamic>))
.toList();
} catch (e) {
debugPrint(e.toString());
rethrow;
}
}
}
Original file line number Diff line number Diff line change
@@ -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';
Copy link

Copilot AI Dec 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The import path contains a typo: "contoributor" should be "contributor".

Suggested change
import 'package:dotto/repository/github_contoributor_repository.dart';
import 'package:dotto/repository/github_contributor_repository.dart';

Copilot uses AI. Check for mistakes.
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>(
GitHubContributorService.new,
);
Comment on lines +12 to +14
Copy link

Copilot AI Dec 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Provider naming should follow Dart naming conventions. Variable names should use lowerCamelCase, not UpperCamelCase. This should be gitHubContributorServiceProvider instead of GitHubContributorServiceProvider.

Copilot uses AI. Check for mistakes.

@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);
Copy link

Copilot AI Dec 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent provider usage. Line 54 uses githubContributionRepositoryProvider which is the repository provider, but the test should be reading the service provider. This appears to be testing the repository directly instead of the service layer.

Suggested change
final service = container.read(githubContributionRepositoryProvider);
final service = container.read(GitHubContributorServiceProvider);

Copilot uses AI. Check for mistakes.

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 => <GitHubProfile>[]);

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<Exception>()));

verify(githubContributorRepository.getContributors()).called(1);
});
});
}
Original file line number Diff line number Diff line change
@@ -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<List<_i4.GitHubProfile>> getContributors() =>
(super.noSuchMethod(
Invocation.method(#getContributors, []),
returnValue: _i3.Future<List<_i4.GitHubProfile>>.value(
<_i4.GitHubProfile>[],
),
)
as _i3.Future<List<_i4.GitHubProfile>>);
}
Loading