├── lib ├── constants │ ├── map.dart │ ├── snack_bar.dart │ ├── number.dart │ ├── localization.dart │ └── string.dart ├── utils │ ├── extensions │ │ ├── string.dart │ │ ├── map.dart │ │ ├── dio.dart │ │ ├── build_context.dart │ │ ├── int.dart │ │ ├── exception.dart │ │ └── iterable.dart │ ├── types.dart │ ├── connectivity.dart │ ├── exceptions │ │ ├── common_exceptions.dart │ │ ├── base.dart │ │ └── api_exceptions.dart │ ├── bool.dart │ ├── timer.dart │ ├── provider_scope.dart │ ├── route.dart │ ├── dio_interceptors │ │ ├── header_interceptor.dart │ │ ├── connectivity_interceptor.dart │ │ ├── response_interceptor.dart │ │ ├── request_interceptor.dart │ │ └── mock_interceptor.dart │ └── api.dart ├── providers │ ├── common │ │ ├── use_mock.dart │ │ ├── github_access_token.dart │ │ ├── application_documents_directory.dart │ │ ├── cookie.dart │ │ └── dio.dart │ ├── bottom_tab │ │ └── bottom_tab.dart │ ├── overlay_loading │ │ └── overlay_loading.dart │ ├── application │ │ ├── application_state.dart │ │ ├── application.dart │ │ └── application_state.freezed.dart │ ├── issue │ │ ├── create_issue_dialog_state.dart │ │ ├── fetch_issue_state.dart │ │ ├── create_issue_dialog.dart │ │ ├── fetch_issue.dart │ │ └── create_issue_dialog_state.freezed.dart │ ├── todo │ │ └── todo.dart │ └── repo │ │ ├── search_repo_state.dart │ │ └── search_repo.dart ├── models │ ├── todo │ │ ├── todo.dart │ │ └── todo.freezed.dart │ ├── repo │ │ ├── owner │ │ │ ├── owner.dart │ │ │ ├── owner.g.dart │ │ │ └── owner.freezed.dart │ │ ├── repo.dart │ │ └── repo.g.dart │ ├── response_data │ │ ├── response_result │ │ │ └── response_result.dart │ │ ├── issue_response │ │ │ ├── issue_response.g.dart │ │ │ └── issue_response.dart │ │ ├── issues_response │ │ │ ├── issues_response.g.dart │ │ │ └── issues_response.dart │ │ ├── base_response_data │ │ │ ├── base_response_data.g.dart │ │ │ └── base_response_data.dart │ │ └── search_repo_response │ │ │ ├── search_repo_response.dart │ │ │ └── search_repo_response.g.dart │ ├── route_arg │ │ └── todo_page │ │ │ └── todo_page.dart │ ├── issue │ │ ├── issue.dart │ │ └── issue.g.dart │ └── json_converter.dart ├── main.dart ├── pages │ ├── not_found │ │ └── not_found_page.dart │ ├── second │ │ └── second_page.dart │ ├── first │ │ └── first_page.dart │ ├── todo │ │ └── todo_page.dart │ ├── main │ │ └── main_page.dart │ ├── home │ │ └── home_page.dart │ ├── repo │ │ └── repo_page.dart │ └── issue │ │ └── issue_page.dart ├── widgets │ ├── common_text.dart │ ├── fetch_summary.dart │ ├── pager.dart │ ├── loading.dart │ ├── main_stacked_pages_navigator.dart │ ├── repo │ │ ├── text_field.dart │ │ └── repo_item.dart │ ├── root.dart │ ├── scaffold_messenger_navigator.dart │ └── issue │ │ ├── issue_item.dart │ │ └── create_issue_dialog.dart ├── route │ ├── go_router.dart │ ├── routes.dart │ ├── bottom_tabs.dart │ └── router.dart ├── app.dart ├── repositories │ ├── search_repo.dart │ └── issue.dart └── services │ ├── navigation.dart │ ├── abstract_api_client.dart │ ├── scaffold_messenger.dart │ └── shared_preferences.dart ├── ios ├── Runner │ ├── Runner-Bridging-Header.h │ ├── Assets.xcassets │ │ ├── LaunchImage.imageset │ │ │ ├── LaunchImage.png │ │ │ ├── LaunchImage@2x.png │ │ │ ├── LaunchImage@3x.png │ │ │ ├── README.md │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ ├── Icon-App-20x20@1x.png │ │ │ ├── Icon-App-20x20@2x.png │ │ │ ├── Icon-App-20x20@3x.png │ │ │ ├── Icon-App-29x29@1x.png │ │ │ ├── Icon-App-29x29@2x.png │ │ │ ├── Icon-App-29x29@3x.png │ │ │ ├── Icon-App-40x40@1x.png │ │ │ ├── Icon-App-40x40@2x.png │ │ │ ├── Icon-App-40x40@3x.png │ │ │ ├── Icon-App-60x60@2x.png │ │ │ ├── Icon-App-60x60@3x.png │ │ │ ├── Icon-App-76x76@1x.png │ │ │ ├── Icon-App-76x76@2x.png │ │ │ ├── Icon-App-1024x1024@1x.png │ │ │ ├── Icon-App-83.5x83.5@2x.png │ │ │ └── Contents.json │ ├── AppDelegate.swift │ ├── Base.lproj │ │ ├── Main.storyboard │ │ └── LaunchScreen.storyboard │ └── Info.plist ├── Flutter │ ├── Debug.xcconfig │ ├── Release.xcconfig │ └── AppFrameworkInfo.plist ├── Runner.xcodeproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── WorkspaceSettings.xcsettings │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── xcschemes │ │ └── Runner.xcscheme ├── Runner.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── WorkspaceSettings.xcsettings │ │ └── IDEWorkspaceChecks.plist ├── .gitignore ├── Podfile └── Podfile.lock ├── .fvm └── fvm_config.json ├── android ├── gradle.properties ├── app │ ├── src │ │ ├── main │ │ │ ├── res │ │ │ │ ├── mipmap-hdpi │ │ │ │ │ └── ic_launcher.png │ │ │ │ ├── mipmap-mdpi │ │ │ │ │ └── ic_launcher.png │ │ │ │ ├── mipmap-xhdpi │ │ │ │ │ └── ic_launcher.png │ │ │ │ ├── mipmap-xxhdpi │ │ │ │ │ └── ic_launcher.png │ │ │ │ ├── mipmap-xxxhdpi │ │ │ │ │ └── ic_launcher.png │ │ │ │ ├── drawable │ │ │ │ │ └── launch_background.xml │ │ │ │ ├── drawable-v21 │ │ │ │ │ └── launch_background.xml │ │ │ │ ├── values │ │ │ │ │ └── styles.xml │ │ │ │ └── values-night │ │ │ │ │ └── styles.xml │ │ │ ├── kotlin │ │ │ │ └── com │ │ │ │ │ └── kosukesaigusa │ │ │ │ │ └── flutterGitHubSearch │ │ │ │ │ └── flutter_github_search │ │ │ │ │ └── MainActivity.kt │ │ │ └── AndroidManifest.xml │ │ ├── debug │ │ │ └── AndroidManifest.xml │ │ └── profile │ │ │ └── AndroidManifest.xml │ └── build.gradle ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties ├── .gitignore ├── settings.gradle └── build.gradle ├── .vscode ├── base_settings.json ├── base_launch.json └── tasks.json ├── .metadata ├── test ├── unit_tests │ └── reg_exp_test.dart ├── repositories │ └── issue_test.dart ├── env │ └── env.dart ├── README.md ├── pages │ ├── second │ │ └── second_page_test.dart │ ├── first │ │ └── first_page_test.dart │ ├── not_found │ │ └── not_found_page_test.dart │ ├── home │ │ └── home_page_test.dart │ ├── issue │ │ └── issue_page_test.dart │ └── repo │ │ └── repo_page_test.dart ├── widgets │ └── test_scaffold_wrapper.dart └── import_all_test.dart ├── assets └── json │ └── mock │ ├── repos │ └── issues.json │ └── search │ └── repositories.json ├── README.md ├── scripts └── import_all_for_coverage.sh ├── .gitignore ├── pubspec.yaml └── analysis_options.yaml /lib/constants/map.dart: -------------------------------------------------------------------------------- 1 | const emptyMap = {}; 2 | -------------------------------------------------------------------------------- /ios/Runner/Runner-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #import "GeneratedPluginRegistrant.h" 2 | -------------------------------------------------------------------------------- /.fvm/fvm_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "flutterSdkVersion": "3.0.1", 3 | "flavors": {} 4 | } -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx1536M 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | -------------------------------------------------------------------------------- /ios/Flutter/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /ios/Flutter/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kosukesaigusa/flutter-github-search/HEAD/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kosukesaigusa/flutter-github-search/HEAD/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kosukesaigusa/flutter-github-search/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /lib/utils/extensions/string.dart: -------------------------------------------------------------------------------- 1 | extension StringExtension on String { 2 | String ifIsEmpty(String placeholder) { 3 | return isEmpty ? placeholder : this; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kosukesaigusa/flutter-github-search/HEAD/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kosukesaigusa/flutter-github-search/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /lib/utils/types.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import 'route.dart'; 4 | 5 | typedef PageBuilder = Widget Function(BuildContext context, RouteArguments args); 6 | -------------------------------------------------------------------------------- /lib/providers/common/use_mock.dart: -------------------------------------------------------------------------------- 1 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 2 | 3 | /// モックを使用するかどうかの真偽値を提供するプロバイダ。 4 | final useMockProvider = Provider((_) => false); 5 | -------------------------------------------------------------------------------- /.vscode/base_settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "dart.flutterSdkPath": ".fvm/flutter_sdk", 3 | "dart.sdkPath": ".fvm/flutter_sdk/bin/cache/dart-sdk", 4 | "dart.runPubGetOnPubspecChanges": false 5 | } 6 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kosukesaigusa/flutter-github-search/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png -------------------------------------------------------------------------------- /lib/constants/snack_bar.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | const defaultSnackBarBehavior = SnackBarBehavior.floating; 4 | const defaultSnackBarDuration = Duration(seconds: 3); 5 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kosukesaigusa/flutter-github-search/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kosukesaigusa/flutter-github-search/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png -------------------------------------------------------------------------------- /lib/utils/extensions/map.dart: -------------------------------------------------------------------------------- 1 | extension MapExt on Map { 2 | dynamic getByKey(String key) { 3 | if (containsKey(key)) { 4 | return this[key]!; 5 | } 6 | return null; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kosukesaigusa/flutter-github-search/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kosukesaigusa/flutter-github-search/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kosukesaigusa/flutter-github-search/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kosukesaigusa/flutter-github-search/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kosukesaigusa/flutter-github-search/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kosukesaigusa/flutter-github-search/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kosukesaigusa/flutter-github-search/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kosukesaigusa/flutter-github-search/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kosukesaigusa/flutter-github-search/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kosukesaigusa/flutter-github-search/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kosukesaigusa/flutter-github-search/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kosukesaigusa/flutter-github-search/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kosukesaigusa/flutter-github-search/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kosukesaigusa/flutter-github-search/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kosukesaigusa/flutter-github-search/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /lib/providers/bottom_tab/bottom_tab.dart: -------------------------------------------------------------------------------- 1 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 2 | 3 | import '../../route/bottom_tabs.dart'; 4 | 5 | final bottomTabStateProvider = StateProvider((_) => bottomTabs[0]); 6 | -------------------------------------------------------------------------------- /lib/providers/overlay_loading/overlay_loading.dart: -------------------------------------------------------------------------------- 1 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 2 | 3 | /// アプリ全体に二度押し防止のローディングスクリーンを重ねるかどうか 4 | final overlayLoadingProvider = StateProvider.autoDispose((ref) => false); 5 | -------------------------------------------------------------------------------- /lib/providers/common/github_access_token.dart: -------------------------------------------------------------------------------- 1 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 2 | 3 | /// GitHub のアクセストークンを提供するプロバイダ 4 | final gitHubAccessTokenProvider = 5 | StateProvider.autoDispose((_) => const String.fromEnvironment('GITHUB_ACCESS_TOKEN')); 6 | -------------------------------------------------------------------------------- /lib/utils/connectivity.dart: -------------------------------------------------------------------------------- 1 | import 'package:connectivity_plus/connectivity_plus.dart'; 2 | 3 | /// インターネットに接続しているかどうか 4 | Future get isNetworkConnected async { 5 | final result = await Connectivity().checkConnectivity(); 6 | return result != ConnectivityResult.none; 7 | } 8 | -------------------------------------------------------------------------------- /android/app/src/main/kotlin/com/kosukesaigusa/flutterGitHubSearch/flutter_github_search/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.kosukesaigusa.flutterGitHubSearch.flutter_github_search 2 | 3 | import io.flutter.embedding.android.FlutterActivity 4 | 5 | class MainActivity: FlutterActivity() { 6 | } 7 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Jun 23 08:50:38 CEST 2017 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip 7 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /lib/providers/common/application_documents_directory.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 4 | 5 | /// cookie の保存先としても使用する ApplicationDocumentDirectory のプロバイダ 6 | final applicationDocumentsDirectoryProvider = 7 | Provider((_) => throw UnimplementedError()); 8 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /lib/constants/number.dart: -------------------------------------------------------------------------------- 1 | /// API通信タイムアウトまでの時間(ミリ秒) 2 | const connectionTimeoutMilliSeconds = 100000; 3 | 4 | /// APIレスポンスタイムアウトまでの時間(ミリ秒) 5 | const receiveTimeoutMilliSeconds = 100000; 6 | 7 | /// GitHub Search Repository API をコールする最低周期の Duration(ミリ秒) 8 | const minSearchApiCallPeriodDuration = Duration(milliseconds: 1000); 9 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /lib/utils/extensions/dio.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/dio.dart'; 2 | 3 | extension DioErrorTypeExt on DioErrorType { 4 | /// DioErrorType がタイムアウトに該当するかどうか 5 | bool get isTimeout => [ 6 | DioErrorType.connectTimeout, 7 | DioErrorType.receiveTimeout, 8 | DioErrorType.sendTimeout, 9 | ].contains(this); 10 | } 11 | -------------------------------------------------------------------------------- /lib/providers/application/application_state.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | part 'application_state.freezed.dart'; 4 | 5 | @freezed 6 | class ApplicationState with _$ApplicationState { 7 | const factory ApplicationState({ 8 | @Default(false) bool loading, 9 | }) = _ApplicationState; 10 | } 11 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: c860cba910319332564e1e9d470a17074c1f2dfd 8 | channel: stable 9 | 10 | project_type: app 11 | -------------------------------------------------------------------------------- /lib/models/todo/todo.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | part 'todo.freezed.dart'; 4 | 5 | /// ThirdPage で用いる実装テスト用のクラス。 6 | @freezed 7 | class Todo with _$Todo { 8 | const factory Todo({ 9 | required int id, 10 | required String title, 11 | @Default(false) bool isDone, 12 | }) = _Todo; 13 | } 14 | -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | gradle-wrapper.jar 2 | /.gradle 3 | /captures/ 4 | /gradlew 5 | /gradlew.bat 6 | /local.properties 7 | GeneratedPluginRegistrant.java 8 | 9 | # Remember to never publicly share your keystore. 10 | # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app 11 | key.properties 12 | **/*.keystore 13 | **/*.jks 14 | -------------------------------------------------------------------------------- /lib/constants/localization.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_localizations/flutter_localizations.dart'; 3 | 4 | const locale = Locale('ja', 'JP'); 5 | 6 | const localizationsDelegates = [ 7 | GlobalMaterialLocalizations.delegate, 8 | GlobalWidgetsLocalizations.delegate, 9 | GlobalCupertinoLocalizations.delegate, 10 | ]; 11 | -------------------------------------------------------------------------------- /lib/utils/exceptions/common_exceptions.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'base.dart'; 4 | 5 | /// Platform が iOS, Android のどちらにも該当しないときに使用する Exception 6 | class UnsupportedPlatformException extends AppException { 7 | const UnsupportedPlatformException() : super(); 8 | 9 | @override 10 | String get message => '${Platform.operatingSystem} はサポートされていません。'; 11 | } 12 | -------------------------------------------------------------------------------- /lib/utils/extensions/build_context.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | extension BuildContextExtension on BuildContext { 4 | /// テーマ 5 | ThemeData get theme => Theme.of(this); 6 | 7 | /// テキストのテーマ 8 | TextTheme get textTheme => Theme.of(this).textTheme; 9 | 10 | /// ディスプレイサイズ 11 | Size get displaySize => MediaQuery.of(this).size; 12 | } 13 | -------------------------------------------------------------------------------- /lib/providers/issue/create_issue_dialog_state.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | part 'create_issue_dialog_state.freezed.dart'; 4 | 5 | @freezed 6 | class CreateIssueDialogState with _$CreateIssueDialogState { 7 | const factory CreateIssueDialogState({ 8 | @Default(false) bool sending, 9 | }) = _CreateIssueDialogState; 10 | } 11 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md: -------------------------------------------------------------------------------- 1 | # Launch Screen Assets 2 | 3 | You can customize the launch screen with your own desired assets by replacing the image files in this directory. 4 | 5 | You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. -------------------------------------------------------------------------------- /lib/utils/extensions/int.dart: -------------------------------------------------------------------------------- 1 | import 'package:intl/intl.dart'; 2 | 3 | final _threeDigitsFormatter = NumberFormat('#,###'); 4 | 5 | extension IntExtension on int { 6 | /// 3 桁区切りのコンマを付加する。 7 | String get withComma => _threeDigitsFormatter.format(this); 8 | 9 | /// 数字に 3 桁区切りのコンマを付加、末尾に「円」を付けた文字列を返す。 10 | String toJpy(int number) => '${_threeDigitsFormatter.format(number)} 円'; 11 | } 12 | -------------------------------------------------------------------------------- /lib/utils/bool.dart: -------------------------------------------------------------------------------- 1 | /// dynamic 型を bool 型に変換する。 2 | bool toBool(dynamic value) { 3 | if (value == null) { 4 | return false; 5 | } 6 | if (value is bool) { 7 | return value; 8 | } 9 | if (value is int) { 10 | return value == 0; 11 | } 12 | if (value is String) { 13 | return value == '1' || value == 'true' || value == 'True' || value == 'TRUE'; 14 | } 15 | return false; 16 | } 17 | -------------------------------------------------------------------------------- /android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /lib/providers/common/cookie.dart: -------------------------------------------------------------------------------- 1 | import 'package:cookie_jar/cookie_jar.dart'; 2 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 3 | 4 | import 'application_documents_directory.dart'; 5 | 6 | /// CookieJar のプロバイダ 7 | final cookieJarProvider = Provider( 8 | (ref) => PersistCookieJar( 9 | ignoreExpires: true, 10 | storage: FileStorage(ref.read(applicationDocumentsDirectoryProvider).path), 11 | ), 12 | ); 13 | -------------------------------------------------------------------------------- /lib/providers/todo/todo.dart: -------------------------------------------------------------------------------- 1 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 2 | 3 | import '../../models/todo/todo.dart'; 4 | 5 | /// API 通信などを模擬して、 6 | /// 1 秒経ったら指定した id の ToDo インスタンスを返す FutureProvider。 7 | final todoFutureProvider = FutureProvider.autoDispose.family((ref, id) async { 8 | return Future.delayed( 9 | const Duration(seconds: 1), 10 | () => Todo(id: id, title: '完了した Todo', isDone: true), 11 | ); 12 | }); 13 | -------------------------------------------------------------------------------- /lib/utils/extensions/exception.dart: -------------------------------------------------------------------------------- 1 | import '../../constants/string.dart'; 2 | 3 | extension ExceptionExtension on Exception { 4 | /// SnackBar などで表示して差し支えのないメッセージを取得する 5 | String get displayMessage { 6 | final message = toString() 7 | .trimLeft() 8 | .trimRight() 9 | .replaceAll('Exception: ', '') 10 | .replaceAll(RegExp(r'^Exception$'), ''); 11 | return message.isEmpty ? generalExceptionMessage : message; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /ios/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Flutter 3 | 4 | @UIApplicationMain 5 | @objc class AppDelegate: FlutterAppDelegate { 6 | override func application( 7 | _ application: UIApplication, 8 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 9 | ) -> Bool { 10 | GeneratedPluginRegistrant.register(with: self) 11 | return super.application(application, didFinishLaunchingWithOptions: launchOptions) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/unit_tests/reg_exp_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_github_search/utils/dio_interceptors/mock_interceptor.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | 4 | void main() { 5 | test('正規表現による MockInterceptor の API URL パスマッチングのテスト', () { 6 | final regExp = MockInterceptor.listRepoIssuesRegExp; 7 | const path = '/repos/ownerName/repository-name/issues'; 8 | final matched = regExp.hasMatch(path); 9 | expect(matched, true); 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /lib/constants/string.dart: -------------------------------------------------------------------------------- 1 | // "Referrer" の誤字 (https://en.wikipedia.org/wiki/HTTP_referer) でややこしいので定数化しておく 2 | const referrer = 'Referer'; 3 | 4 | // API のベース URL 5 | const apiBaseURL = 'https://api.github.com'; 6 | 7 | // エラーメッセージ関係で複数回使用しているもの 8 | const generalExceptionMessage = 'エラーが発生しました。'; 9 | const networkNotConnected = 'ネットワーク接続がありません。'; 10 | const responseFormatNotValid = 'レスポンスの形式が正しくありません。'; 11 | const emptyQMessage = 'GitHub の Search Repository API で検索したいキーワードを入力してください。'; 12 | -------------------------------------------------------------------------------- /lib/utils/timer.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | /// Debounce(duration: Duration).run() で 6 | /// 所定の時間経過後にコールバック関数を発火させる。 7 | class Debounce { 8 | Debounce({required this.duration}); 9 | 10 | final Duration duration; 11 | Timer? _timer; 12 | 13 | void run(VoidCallback callback) { 14 | if (_timer?.isActive ?? false) { 15 | _timer?.cancel(); 16 | } 17 | _timer = Timer(duration, callback); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /lib/models/repo/owner/owner.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | part 'owner.freezed.dart'; 4 | part 'owner.g.dart'; 5 | 6 | @freezed 7 | class Owner with _$Owner { 8 | const factory Owner({ 9 | required int id, 10 | @Default('') @JsonKey(name: 'avatar_url') String avatarUrl, 11 | @Default('') @JsonKey(name: 'html_url') String htmlUrl, 12 | }) = _Owner; 13 | 14 | factory Owner.fromJson(Map json) => _$OwnerFromJson(json); 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/base_launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug", 6 | "program": "lib/main.dart", 7 | "request": "launch", 8 | "type": "dart", 9 | "args": [ 10 | "--debug", 11 | "--dart-define=TARGET_GITHUB_REPO={Target GITHUB repository name}", 12 | "--dart-define=GITHUB_OWNER_NAME={GitHub owner name}", 13 | "--dart-define=GITHUB_ACCESS_TOKEN={GitHub Personal access token}" 14 | ] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | 3 | def localPropertiesFile = new File(rootProject.projectDir, "local.properties") 4 | def properties = new Properties() 5 | 6 | assert localPropertiesFile.exists() 7 | localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } 8 | 9 | def flutterSdkPath = properties.getProperty("flutter.sdk") 10 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties" 11 | apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" 12 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-v21/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /test/repositories/issue_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | 3 | import '../env/env.dart'; 4 | 5 | void main() { 6 | TestWidgetsFlutterBinding.ensureInitialized(); 7 | test('Environmental variables test', () async { 8 | final envVars = await fetchEnvVars; 9 | final targetRepo = envVars['TARGET_GITHUB_REPO'] as String; 10 | final ownerName = envVars['GITHUB_OWNER_NAME'] as String; 11 | expect(targetRepo, 'flutter-github-search'); 12 | expect(ownerName, 'KosukeSaigusa'); 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "LaunchImage.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "LaunchImage@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "LaunchImage@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/models/response_data/response_result/response_result.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../base_response_data/base_response_data.dart'; 4 | 5 | part 'response_result.freezed.dart'; 6 | 7 | @freezed 8 | class ResponseResult with _$ResponseResult { 9 | /// 成功 10 | const factory ResponseResult.success({ 11 | required BaseResponseData data, 12 | }) = Success; 13 | 14 | /// 失敗 15 | const factory ResponseResult.failure({ 16 | @Default('サーバとの通信に失敗しました。') String message, 17 | }) = Failure; 18 | } 19 | -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/services.dart'; 3 | 4 | import 'app.dart'; 5 | import 'utils/provider_scope.dart'; 6 | import 'widgets/root.dart'; 7 | 8 | Future main() async { 9 | WidgetsFlutterBinding.ensureInitialized(); 10 | // 画面の向きを固定 11 | await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); 12 | final overrides = await providerScopeOverrides; 13 | runApp( 14 | RootWidget( 15 | overrides: overrides, 16 | widget: const App(), 17 | ), 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /lib/providers/application/application.dart: -------------------------------------------------------------------------------- 1 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 2 | 3 | import 'application_state.dart'; 4 | 5 | final applicationStateNotifierProvider = 6 | StateNotifierProvider( 7 | (ref) => ApplicationStateNotifierProvider(), 8 | ); 9 | 10 | /// アプリケーション全体で保持・操作したい状態をまとめる 11 | /// いまは特にそのような用途がないため実装内容は空っぽ。 12 | class ApplicationStateNotifierProvider extends StateNotifier { 13 | ApplicationStateNotifierProvider() : super(const ApplicationState()); 14 | } 15 | -------------------------------------------------------------------------------- /test/env/env.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:flutter/services.dart'; 4 | 5 | const jsonFilePath = 'assets/json/env/env.json'; 6 | 7 | /// テストでは毎度 dart-define を指定して実行するのが面倒なので 8 | /// env.json に書いた環境変数に対応する値をこのメソッドで読み込んで使用する。 9 | Future> get fetchEnvVars async { 10 | final data = await rootBundle.load(jsonFilePath); 11 | return json.decode( 12 | utf8.decode( 13 | data.buffer.asUint8List( 14 | data.offsetInBytes, 15 | data.lengthInBytes, 16 | ), 17 | ), 18 | ) as Map; 19 | } 20 | -------------------------------------------------------------------------------- /lib/pages/not_found/not_found_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class NotFoundPage extends StatelessWidget { 4 | const NotFoundPage({super.key, this.exception}); 5 | 6 | final Exception? exception; 7 | 8 | static const path = '/not-found/'; 9 | static const name = 'NotFoundPage'; 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | return Scaffold( 14 | body: Center( 15 | child: Text( 16 | exception == null ? '指定されたページが見つかりませんでした。' : exception.toString(), 17 | ), 18 | ), 19 | ); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /lib/models/route_arg/todo_page/todo_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../../todo/todo.dart'; 4 | 5 | part 'todo_page.freezed.dart'; 6 | 7 | /// TodoPage に渡すべきルート引数に 8 | /// インスタンスを直接渡すパターンと 9 | /// id を渡して、画面描画前に fetch をするパターンとがあるので 10 | /// それらを freezed で Union/Sealed クラス的に定義する。 11 | @freezed 12 | class TodoPageRouteArgument with _$TodoPageRouteArgument { 13 | /// インスタンスを直接渡すパターン。 14 | const factory TodoPageRouteArgument.instance(Todo todo) = Instance; 15 | 16 | /// id を渡すパターン。 17 | const factory TodoPageRouteArgument.id(int id) = Id; 18 | } 19 | -------------------------------------------------------------------------------- /assets/json/mock/repos/issues.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 2, 4 | "html_url": "https://github.com/ownerName/test-repository-name/issues/2", 5 | "title": "Test issue title 2", 6 | "body": "Test issue body 2", 7 | "number": 2, 8 | "state": "open", 9 | "created_at": "2022-01-02T00:00:00Z" 10 | }, 11 | { 12 | "id": 1, 13 | "html_url": "https://github.com/ownerName/test-repository-name/issues/1", 14 | "title": "Test issue title 1", 15 | "body": "Test issue body 1", 16 | "number": 1, 17 | "state": "open", 18 | "created_at": "2022-01-01T00:00:00Z" 19 | } 20 | ] 21 | -------------------------------------------------------------------------------- /lib/models/issue/issue.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | part 'issue.freezed.dart'; 4 | part 'issue.g.dart'; 5 | 6 | @freezed 7 | class Issue with _$Issue { 8 | const factory Issue({ 9 | required int id, 10 | @JsonKey(name: 'html_url') required String htmlUrl, 11 | @Default('') String title, 12 | @Default('') String body, 13 | @Default(0) int number, 14 | @Default('open') String state, 15 | @JsonKey(name: 'created_at') required DateTime createdAt, 16 | }) = _Issue; 17 | 18 | factory Issue.fromJson(Map json) => _$IssueFromJson(json); 19 | } 20 | -------------------------------------------------------------------------------- /lib/utils/exceptions/base.dart: -------------------------------------------------------------------------------- 1 | /// アプリ内で使用する例外型。 2 | class AppException implements Exception { 3 | const AppException({ 4 | this.code, 5 | this.message, 6 | this.defaultMessage = 'エラーが発生しました。', 7 | }); 8 | 9 | /// ステータスコードや独自のエラーコードなどのエラー種別を識別するための文字列 10 | final String? code; 11 | 12 | /// 例外の内容を説明するメッセージ 13 | final String? message; 14 | 15 | /// message が空の場合に使用されるメッセージ 16 | final String defaultMessage; 17 | 18 | @override 19 | String toString() { 20 | if (code == null) { 21 | return message ?? defaultMessage; 22 | } 23 | return '[$code] ${message ?? defaultMessage}'; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /lib/widgets/common_text.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | /// Repository 一覧ページや Issues 一覧のページに表示するテキスト 4 | class CommonTextWidget extends StatelessWidget { 5 | const CommonTextWidget( 6 | this.text, { 7 | super.key, 8 | }); 9 | 10 | final String text; 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | return Padding( 15 | padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), 16 | child: Text( 17 | text, 18 | style: const TextStyle( 19 | fontSize: 12, 20 | color: Colors.black54, 21 | ), 22 | ), 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /lib/utils/extensions/iterable.dart: -------------------------------------------------------------------------------- 1 | extension IterableEx on Iterable { 2 | E? firstWhereOrNull(bool Function(E element) test) { 3 | for (final element in this) { 4 | if (test(element)) { 5 | return element; 6 | } 7 | } 8 | return null; 9 | } 10 | 11 | E? lastWhereOrNull(bool Function(E element) test) { 12 | late E result; 13 | var foundMatching = false; 14 | for (final element in this) { 15 | if (test(element)) { 16 | result = element; 17 | foundMatching = true; 18 | } 19 | } 20 | if (foundMatching) { 21 | return result; 22 | } 23 | return null; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | # test 2 | 3 | ## How to test 4 | 5 | Reference: [How to Generate and Analyze a Flutter Test Coverage Report in VSCode](https://codewithandrea.com/articles/flutter-test-coverage/) 6 | 7 | ```shell 8 | # Generate `coverage/lcov.info` file 9 | flutter test --coverage 10 | 11 | # Generate HTML report 12 | # Note: on macOS you need to have lcov installed on your system (`brew install lcov`) to use this: 13 | genhtml coverage/lcov.info -o coverage/html 14 | 15 | # Open the report 16 | open coverage/html/index.html 17 | ``` 18 | 19 | ## Add every .dart file to test coverage calculation 20 | 21 | ```shell 22 | ./scripts/import_all_for_coverage.sh 23 | ``` 24 | -------------------------------------------------------------------------------- /assets/json/mock/search/repositories.json: -------------------------------------------------------------------------------- 1 | { 2 | "total_count": 374364, 3 | "incomplete_results": false, 4 | "items": [ 5 | { 6 | "id": 14101776, 7 | "name": "flutter", 8 | "owner": { 9 | "id": 14101776, 10 | "avatar_url": "https://avatars.githubusercontent.com/u/14101776?v=4", 11 | "html_url": "https://github.com/flutter" 12 | }, 13 | "html_url": "https://github.com/flutter/flutter", 14 | "description": "Flutter makes it easy and fast to build beautiful apps for mobile and beyond", 15 | "updated_at": "2022-05-15T05:11:18Z", 16 | "stargazers_count": 140182, 17 | "forks_count": 22000 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /lib/providers/issue/fetch_issue_state.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../../models/issue/issue.dart'; 4 | import '../../utils/api.dart'; 5 | 6 | part 'fetch_issue_state.freezed.dart'; 7 | 8 | @freezed 9 | class FetchIssueState with _$FetchIssueState { 10 | const factory FetchIssueState({ 11 | @Default(false) bool loading, 12 | @Default(FetchResponseError.none) FetchResponseError error, 13 | @Default(false) bool canShowPreviousPage, 14 | @Default(false) bool canShowNextPage, 15 | @Default(1) int currentPage, 16 | @Default(10) int perPage, 17 | @Default([]) List issues, 18 | }) = _FetchIssueState; 19 | } 20 | -------------------------------------------------------------------------------- /lib/utils/provider_scope.dart: -------------------------------------------------------------------------------- 1 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 2 | import 'package:path_provider/path_provider.dart'; 3 | import 'package:shared_preferences/shared_preferences.dart'; 4 | 5 | import '../providers/common/application_documents_directory.dart'; 6 | import '../services/shared_preferences.dart'; 7 | 8 | /// RootProviderScope で指定する List を取得する。 9 | Future> get providerScopeOverrides async { 10 | return [ 11 | applicationDocumentsDirectoryProvider.overrideWithValue( 12 | await getApplicationDocumentsDirectory(), 13 | ), 14 | sharedPreferencesProvider.overrideWithValue( 15 | await SharedPreferences.getInstance(), 16 | ), 17 | ]; 18 | } 19 | -------------------------------------------------------------------------------- /lib/utils/route.dart: -------------------------------------------------------------------------------- 1 | import '../utils/types.dart'; 2 | 3 | /// Route 引数に指定する型。中身は Map 4 | class RouteArguments { 5 | RouteArguments(this.data); 6 | 7 | final Map data; 8 | 9 | dynamic operator [](String key) => data[key]; 10 | } 11 | 12 | /// パス文字列と対応するウィジェットを返すビルダーからなる 13 | /// ルートに関するクラス。 14 | class AppRoute { 15 | AppRoute(this.path, this.pageBuilder); 16 | 17 | final String path; 18 | final PageBuilder pageBuilder; 19 | } 20 | 21 | /// 指定した Route のパスが見つからない場合の例外 22 | class RouteNotFoundException implements Exception { 23 | RouteNotFoundException(this.path); 24 | final String path; 25 | 26 | @override 27 | String toString() => '$path:指定されたページが見つかりませんでした。'; 28 | } 29 | -------------------------------------------------------------------------------- /ios/.gitignore: -------------------------------------------------------------------------------- 1 | **/dgph 2 | *.mode1v3 3 | *.mode2v3 4 | *.moved-aside 5 | *.pbxuser 6 | *.perspectivev3 7 | **/*sync/ 8 | .sconsign.dblite 9 | .tags* 10 | **/.vagrant/ 11 | **/DerivedData/ 12 | Icon? 13 | **/Pods/ 14 | **/.symlinks/ 15 | profile 16 | xcuserdata 17 | **/.generated/ 18 | Flutter/App.framework 19 | Flutter/Flutter.framework 20 | Flutter/Flutter.podspec 21 | Flutter/Generated.xcconfig 22 | Flutter/ephemeral/ 23 | Flutter/app.flx 24 | Flutter/app.zip 25 | Flutter/flutter_assets/ 26 | Flutter/flutter_export_environment.sh 27 | ServiceDefinitions.json 28 | Runner/GeneratedPluginRegistrant.* 29 | 30 | # Exceptions to above rules. 31 | !default.mode1v3 32 | !default.mode2v3 33 | !default.pbxuser 34 | !default.perspectivev3 35 | -------------------------------------------------------------------------------- /lib/utils/dio_interceptors/header_interceptor.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/dio.dart'; 2 | 3 | import '../../constants/string.dart'; 4 | 5 | /// ヘッダーに認証情報などを付加する 6 | class HeaderInterceptor extends Interceptor { 7 | HeaderInterceptor([this.overwriteUrl]); 8 | 9 | String? overwriteUrl; 10 | 11 | @override 12 | void onRequest( 13 | RequestOptions options, 14 | RequestInterceptorHandler handler, 15 | ) { 16 | // Referer や Origin など、Dio 経由のすべてのリクエストヘッダーに付加したい 17 | // キー・バリューを追加する。 18 | options.headers[referrer] = overwriteUrl ?? options.baseUrl; 19 | options.headers['Origin'] = options.baseUrl; 20 | options.headers['Accept'] = 'application/json'; 21 | return handler.next(options); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext.kotlin_version = '1.6.10' 3 | repositories { 4 | google() 5 | mavenCentral() 6 | } 7 | 8 | dependencies { 9 | classpath 'com.android.tools.build:gradle:4.1.0' 10 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 11 | } 12 | } 13 | 14 | allprojects { 15 | repositories { 16 | google() 17 | mavenCentral() 18 | } 19 | } 20 | 21 | rootProject.buildDir = '../build' 22 | subprojects { 23 | project.buildDir = "${rootProject.buildDir}/${project.name}" 24 | } 25 | subprojects { 26 | project.evaluationDependsOn(':app') 27 | } 28 | 29 | task clean(type: Delete) { 30 | delete rootProject.buildDir 31 | } 32 | -------------------------------------------------------------------------------- /test/pages/second/second_page_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_github_search/pages/second/second_page.dart'; 3 | import 'package:flutter_test/flutter_test.dart'; 4 | 5 | import '../../widgets/test_scaffold_wrapper.dart'; 6 | 7 | void main() { 8 | testWidgets('${SecondPage.name} のテスト', (tester) async { 9 | await tester.pumpWidget( 10 | const TestScaffoldWrapper( 11 | child: SecondPage(), 12 | ), 13 | ); 14 | 15 | // ボタンを押して SnackBar を表示する 16 | await tester.tap(find.byType(ElevatedButton)); 17 | 18 | // 待つ 19 | await tester.pump(); 20 | 21 | // スナックバーが表示されていることを確認する 22 | expect(find.text('A SnackBar is shown.'), findsOneWidget); 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /lib/models/repo/owner/owner.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'owner.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | _$_Owner _$$_OwnerFromJson(Map json) => _$_Owner( 10 | id: json['id'] as int, 11 | avatarUrl: json['avatar_url'] as String? ?? '', 12 | htmlUrl: json['html_url'] as String? ?? '', 13 | ); 14 | 15 | Map _$$_OwnerToJson(_$_Owner instance) => { 16 | 'id': instance.id, 17 | 'avatar_url': instance.avatarUrl, 18 | 'html_url': instance.htmlUrl, 19 | }; 20 | -------------------------------------------------------------------------------- /lib/utils/dio_interceptors/connectivity_interceptor.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/dio.dart'; 2 | 3 | import '../api.dart'; 4 | import '../connectivity.dart'; 5 | 6 | /// ネットワーク接続を確認しネットワーク接続がない場合はエラーを出す 7 | class ConnectivityInterceptor extends Interceptor { 8 | ConnectivityInterceptor(); 9 | 10 | @override 11 | Future onRequest( 12 | RequestOptions options, 13 | RequestInterceptorHandler handler, 14 | ) async { 15 | if (!await isNetworkConnected) { 16 | return handler.reject( 17 | DioError( 18 | type: DioErrorType.other, 19 | error: ErrorCode.networkNotConnected, 20 | requestOptions: options, 21 | ), 22 | ); 23 | } 24 | return handler.next(options); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lib/models/json_converter.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../constants/map.dart'; 4 | 5 | /// 6 | class BaseResponseDataConverter implements JsonConverter, dynamic> { 7 | const BaseResponseDataConverter(); 8 | 9 | @override 10 | Map fromJson(dynamic data) { 11 | if (data == null) { 12 | return emptyMap; 13 | } 14 | if (data is List) { 15 | // データがリストである場合は 16 | // 'items' のキーをつけて Map> にする。 17 | return >{'items': data}; 18 | } 19 | return data as Map; 20 | } 21 | 22 | @override 23 | Map toJson(Map data) => data; 24 | } 25 | -------------------------------------------------------------------------------- /lib/models/repo/repo.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import 'owner/owner.dart'; 4 | 5 | part 'repo.freezed.dart'; 6 | part 'repo.g.dart'; 7 | 8 | @freezed 9 | class Repo with _$Repo { 10 | const factory Repo({ 11 | required int id, 12 | required String name, 13 | required Owner owner, 14 | @JsonKey(name: 'html_url') required String htmlUrl, 15 | @Default('') String description, 16 | @JsonKey(name: 'updated_at') required DateTime updatedAt, 17 | @Default(0) @JsonKey(name: 'stargazers_count') int starGazersCount, 18 | @Default(0) @JsonKey(name: 'forks_count') int forksCount, 19 | }) = _Repo; 20 | 21 | factory Repo.fromJson(Map json) => _$RepoFromJson(json); 22 | } 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # flutter_github_search 2 | 3 | A new Flutter project. 4 | 5 | ## Getting Started 6 | 7 | This project is a starting point for a Flutter application. 8 | 9 | A few resources to get you started if this is your first Flutter project: 10 | 11 | - [Lab: Write your first Flutter app](https://flutter.dev/docs/get-started/codelab) 12 | - [Cookbook: Useful Flutter samples](https://flutter.dev/docs/cookbook) 13 | 14 | For help getting started with Flutter, view our 15 | [online documentation](https://flutter.dev/docs), which offers tutorials, 16 | samples, guidance on mobile development, and a full API reference. 17 | 18 | ## FVM 19 | 20 | ```shell 21 | fvm releases 22 | fvm install 23 | fvm list 24 | fvm use 25 | fvm install 26 | fvm global 27 | ``` 28 | -------------------------------------------------------------------------------- /lib/providers/repo/search_repo_state.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../../models/repo/repo.dart'; 4 | import '../../utils/api.dart'; 5 | 6 | part 'search_repo_state.freezed.dart'; 7 | 8 | @freezed 9 | class SearchRepoState with _$SearchRepoState { 10 | const factory SearchRepoState({ 11 | @Default(false) bool loading, 12 | @Default(FetchResponseError.none) FetchResponseError error, 13 | @Default(false) bool canShowPreviousPage, 14 | @Default(false) bool canShowNextPage, 15 | @Default('') String q, 16 | @Default(1) int currentPage, 17 | @Default(10) int perPage, 18 | @Default(0) int totalCount, 19 | @Default(1) int maxPage, 20 | @Default([]) List repos, 21 | }) = _SearchRepoState; 22 | } 23 | -------------------------------------------------------------------------------- /test/pages/first/first_page_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_github_search/pages/first/first_page.dart'; 3 | import 'package:flutter_test/flutter_test.dart'; 4 | 5 | import '../../widgets/test_scaffold_wrapper.dart'; 6 | 7 | void main() { 8 | testWidgets('${FirstPage.name} のテスト', (tester) async { 9 | await tester.pumpWidget(const TestScaffoldWrapper(child: FirstPage())); 10 | 11 | // はじめはカウンターの値は 0 である 12 | expect(find.text('0'), findsOneWidget); 13 | expect(find.text('1'), findsNothing); 14 | 15 | // カウンターを 1 回押してインクリメントする 16 | await tester.tap(find.byIcon(Icons.add)); 17 | await tester.pump(); 18 | 19 | // 1 回押した後はカウンターの値は 1 になる 20 | expect(find.text('0'), findsNothing); 21 | expect(find.text('1'), findsOneWidget); 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /scripts/import_all_for_coverage.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | outputFile="$(pwd)/test/import_all_test.dart" 4 | packageName="$(cat pubspec.yaml| grep '^name: ' | awk '{print $2}')" 5 | 6 | if [ "$packageName" = "" ]; then 7 | echo "Run $0 from the root of your Dart/Flutter project" 8 | exit 1 9 | fi 10 | 11 | echo "/// *** GENERATED FILE - ANY CHANGES WOULD BE OBSOLETE ON NEXT GENERATION *** ///\n" > "$outputFile" 12 | echo "// ignore_for_file: unused_import\n" >> "$outputFile" 13 | echo "/// Helper to test coverage for all project files" >> "$outputFile" 14 | find lib -name '*.dart' | grep -v '.g.dart' | grep -v '.freezed.dart' | grep -v 'generated_plugin_registrant' | awk -v package=$packageName '{gsub("^lib", "", $1); printf("import '\''package:%s%s'\'';\n", package, $1);}' >> "$outputFile" 15 | echo "\nvoid main() {}" >> "$outputFile" 16 | -------------------------------------------------------------------------------- /lib/models/response_data/issue_response/issue_response.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'issue_response.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | _$_IssueResponse _$$_IssueResponseFromJson(Map json) => 10 | _$_IssueResponse( 11 | success: json['success'] as bool? ?? true, 12 | message: json['message'] as String? ?? '', 13 | issue: Issue.fromJson(json['issue'] as Map), 14 | ); 15 | 16 | Map _$$_IssueResponseToJson(_$_IssueResponse instance) => 17 | { 18 | 'success': instance.success, 19 | 'message': instance.message, 20 | 'issue': instance.issue, 21 | }; 22 | -------------------------------------------------------------------------------- /lib/widgets/fetch_summary.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import '../utils/extensions/int.dart'; 4 | import 'common_text.dart'; 5 | 6 | /// Repository や Issue の取得結果の件数、現在のページ数、最大ページ数などを 7 | /// 表示するウィジェット 8 | class FetchSummaryWidget extends StatelessWidget { 9 | const FetchSummaryWidget({ 10 | super.key, 11 | required this.totalCount, 12 | required this.currentPage, 13 | required this.maxPage, 14 | }); 15 | 16 | final int totalCount; 17 | final int currentPage; 18 | final int maxPage; 19 | 20 | static const path = '/foo/'; 21 | static const name = 'FetchSummaryWidget'; 22 | 23 | @override 24 | Widget build(BuildContext context) { 25 | return CommonTextWidget( 26 | '全 ${totalCount.withComma} 件' 27 | '(${currentPage.withComma} / ' 28 | '${maxPage.withComma} ' 29 | 'ページ)', 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/utils/dio_interceptors/response_interceptor.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/dio.dart'; 2 | import 'package:flutter/material.dart'; 3 | 4 | /// レスポンスの内容をコンソールに出力する 5 | class ResponseInterceptor extends Interceptor { 6 | @override 7 | void onResponse( 8 | Response response, 9 | ResponseInterceptorHandler handler, 10 | ) { 11 | final stringBuffer = StringBuffer(); 12 | debugPrint('*** Response ***'); 13 | final requestOptions = response.requestOptions; 14 | stringBuffer 15 | .writeln('${requestOptions.method} ${requestOptions.baseUrl}${requestOptions.path}'); 16 | stringBuffer.writeln('${response.statusCode} ${response.statusMessage}'); 17 | // stringBuffer.write(response.headers.toString()); 18 | // stringBuffer.write(response.data.toString()); 19 | debugPrint(stringBuffer.toString()); 20 | super.onResponse(response, handler); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /ios/Flutter/AppFrameworkInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | App 9 | CFBundleIdentifier 10 | io.flutter.flutter.app 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | App 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1.0 23 | MinimumOSVersion 24 | 11.0 25 | 26 | 27 | -------------------------------------------------------------------------------- /lib/models/response_data/issues_response/issues_response.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'issues_response.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | _$_IssuesResponse _$$_IssuesResponseFromJson(Map json) => 10 | _$_IssuesResponse( 11 | success: json['success'] as bool? ?? true, 12 | message: json['message'] as String? ?? '', 13 | issues: (json['items'] as List?) 14 | ?.map((e) => Issue.fromJson(e as Map)) 15 | .toList() ?? 16 | const [], 17 | ); 18 | 19 | Map _$$_IssuesResponseToJson(_$_IssuesResponse instance) => 20 | { 21 | 'success': instance.success, 22 | 'message': instance.message, 23 | 'items': instance.issues, 24 | }; 25 | -------------------------------------------------------------------------------- /lib/models/response_data/base_response_data/base_response_data.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'base_response_data.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | _$_BaseResponseData _$$_BaseResponseDataFromJson(Map json) => 10 | _$_BaseResponseData( 11 | success: json['success'] as bool? ?? true, 12 | message: json['message'] as String? ?? '', 13 | data: json['data'] == null 14 | ? emptyMap 15 | : const BaseResponseDataConverter().fromJson(json['data']), 16 | ); 17 | 18 | Map _$$_BaseResponseDataToJson(_$_BaseResponseData instance) => 19 | { 20 | 'success': instance.success, 21 | 'message': instance.message, 22 | 'data': const BaseResponseDataConverter().toJson(instance.data), 23 | }; 24 | -------------------------------------------------------------------------------- /test/pages/not_found/not_found_page_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_github_search/pages/not_found/not_found_page.dart'; 3 | import 'package:flutter_test/flutter_test.dart'; 4 | 5 | import '../../widgets/test_scaffold_wrapper.dart'; 6 | 7 | void main() { 8 | testWidgets('${NotFoundPage.name} のテスト', (tester) async { 9 | await tester.pumpWidget( 10 | TestScaffoldWrapper( 11 | child: Builder( 12 | builder: (context) => ElevatedButton( 13 | onPressed: () async { 14 | await Navigator.pushNamed(context, '/not-exist-path/'); 15 | }, 16 | child: const Text('Test'), 17 | ), 18 | ), 19 | ), 20 | ); 21 | 22 | // ボタンを押して画面遷移する 23 | await tester.tap(find.byType(ElevatedButton)); 24 | 25 | // しばらく待つ 26 | await tester.pumpAndSettle(); 27 | 28 | // NotFoundPage に遷移していることを確認する 29 | expect(find.byType(NotFoundPage), findsOneWidget); 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /lib/models/response_data/issues_response/issues_response.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../../issue/issue.dart'; 4 | import '../base_response_data/base_response_data.dart'; 5 | 6 | part 'issues_response.freezed.dart'; 7 | part 'issues_response.g.dart'; 8 | 9 | @freezed 10 | class IssuesResponse with _$IssuesResponse { 11 | const factory IssuesResponse({ 12 | @Default(true) bool success, 13 | @Default('') String message, 14 | @Default([]) @JsonKey(name: 'items') List issues, 15 | }) = _IssuesResponse; 16 | 17 | factory IssuesResponse.fromJson(Map json) => _$IssuesResponseFromJson(json); 18 | 19 | factory IssuesResponse.fromBaseResponseData(BaseResponseData baseResponseData) => 20 | IssuesResponse.fromJson( 21 | { 22 | 'success': baseResponseData.success, 23 | 'message': baseResponseData.message, 24 | ...baseResponseData.data, 25 | }, 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /lib/widgets/pager.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | /// リポジトリ一覧・イシュー一覧の下部の前のページ・次のページボタン 4 | class PagerWidget extends StatelessWidget { 5 | const PagerWidget({ 6 | super.key, 7 | required this.canShowPreviousPage, 8 | required this.canShowNextPage, 9 | required this.showPreviousPage, 10 | required this.showNextPage, 11 | }); 12 | 13 | final bool canShowPreviousPage; 14 | final bool canShowNextPage; 15 | final void Function() showPreviousPage; 16 | final void Function() showNextPage; 17 | 18 | @override 19 | Widget build(BuildContext context) { 20 | return Row( 21 | mainAxisAlignment: MainAxisAlignment.spaceAround, 22 | children: [ 23 | TextButton( 24 | onPressed: canShowPreviousPage ? showPreviousPage : null, 25 | child: const Text('前のページ'), 26 | ), 27 | TextButton( 28 | onPressed: canShowNextPage ? showNextPage : null, 29 | child: const Text('次のページ'), 30 | ), 31 | ], 32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/pages/second/second_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:gap/gap.dart'; 3 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 4 | 5 | import '../../services/scaffold_messenger.dart'; 6 | 7 | class SecondPage extends HookConsumerWidget { 8 | const SecondPage({super.key}); 9 | 10 | static const path = '/second/'; 11 | static const name = 'SecondPage'; 12 | 13 | @override 14 | Widget build(BuildContext context, WidgetRef ref) { 15 | return Scaffold( 16 | appBar: AppBar(), 17 | body: Center( 18 | child: Column( 19 | mainAxisAlignment: MainAxisAlignment.center, 20 | children: [ 21 | const Text(name), 22 | const Gap(16), 23 | ElevatedButton( 24 | onPressed: () => 25 | ref.read(scaffoldMessengerServiceProvider).showSnackBar('A SnackBar is shown.'), 26 | child: const Text('Show SnackBar'), 27 | ), 28 | ], 29 | ), 30 | ), 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /lib/route/go_router.dart: -------------------------------------------------------------------------------- 1 | // import 'package:flutter/cupertino.dart'; 2 | // import 'package:go_router/go_router.dart'; 3 | 4 | // import '../pages/first/first_page.dart'; 5 | // import '../pages/main/main_page.dart'; 6 | // import '../pages/not_found/not_found_page.dart'; 7 | // import '../pages/seconde/second_page.dart'; 8 | 9 | // /// 使用していない。実験 10 | // final router = GoRouter( 11 | // routes: [ 12 | // GoRoute( 13 | // path: MainPage.path, 14 | // builder: (context, state) => const MainPage(key: ValueKey(MainPage.name)), 15 | // ), 16 | // GoRoute( 17 | // path: FirstPage.path, 18 | // builder: (context, state) => const FirstPage( 19 | // key: ValueKey(FirstPage.name), 20 | // ), 21 | // ), 22 | // GoRoute( 23 | // path: SecondPage.path, 24 | // builder: (context, state) => const SecondPage( 25 | // key: ValueKey(SecondPage.name), 26 | // ), 27 | // ), 28 | // ], 29 | // errorBuilder: (context, state) => NotFoundPage(exception: state.error), 30 | // ); 31 | -------------------------------------------------------------------------------- /lib/app.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import 'constants/localization.dart'; 4 | import 'widgets/scaffold_messenger_navigator.dart'; 5 | 6 | /// MaterialApp を返すウィジェット。 7 | /// ここではルートは制御せず、home プロパティに 8 | /// ScaffoldMessengerNavigator を指定するだけとする。 9 | class App extends StatelessWidget { 10 | const App({super.key}); 11 | @override 12 | Widget build(BuildContext context) { 13 | return MaterialApp( 14 | key: UniqueKey(), 15 | debugShowCheckedModeBanner: false, 16 | locale: locale, 17 | localizationsDelegates: localizationsDelegates, 18 | supportedLocales: const [locale], 19 | title: 'flutter-github-search', 20 | theme: ThemeData(primarySwatch: Colors.blue).copyWith(), 21 | home: const ScaffoldMessengerNavigator(), 22 | builder: (context, child) { 23 | return MediaQuery( 24 | // 端末依存のフォントスケールを 1 に固定する 25 | data: MediaQuery.of(context).copyWith(textScaleFactor: 1), 26 | child: child!, 27 | ); 28 | }, 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /lib/models/response_data/issue_response/issue_response.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../../issue/issue.dart'; 4 | import '../base_response_data/base_response_data.dart'; 5 | 6 | part 'issue_response.freezed.dart'; 7 | part 'issue_response.g.dart'; 8 | 9 | @freezed 10 | class IssueResponse with _$IssueResponse { 11 | const factory IssueResponse({ 12 | @Default(true) bool success, 13 | @Default('') String message, 14 | required Issue issue, 15 | }) = _IssueResponse; 16 | 17 | factory IssueResponse.fromJson(Map json) => _$IssueResponseFromJson(json); 18 | 19 | factory IssueResponse.fromBaseResponseData(BaseResponseData baseResponseData) => 20 | IssueResponse.fromJson( 21 | { 22 | 'success': baseResponseData.success, 23 | 'message': baseResponseData.message, 24 | // baseResponse.data がまるごとひとつの Issue オブジェクトに対応しているので 25 | // このように issue のキーを付ける必要がある。 26 | 'issue': baseResponseData.data, 27 | }, 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /lib/models/issue/issue.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'issue.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | _$_Issue _$$_IssueFromJson(Map json) => _$_Issue( 10 | id: json['id'] as int, 11 | htmlUrl: json['html_url'] as String, 12 | title: json['title'] as String? ?? '', 13 | body: json['body'] as String? ?? '', 14 | number: json['number'] as int? ?? 0, 15 | state: json['state'] as String? ?? 'open', 16 | createdAt: DateTime.parse(json['created_at'] as String), 17 | ); 18 | 19 | Map _$$_IssueToJson(_$_Issue instance) => { 20 | 'id': instance.id, 21 | 'html_url': instance.htmlUrl, 22 | 'title': instance.title, 23 | 'body': instance.body, 24 | 'number': instance.number, 25 | 'state': instance.state, 26 | 'created_at': instance.createdAt.toIso8601String(), 27 | }; 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | 12 | # IntelliJ related 13 | *.iml 14 | *.ipr 15 | *.iws 16 | .idea/ 17 | 18 | # The .vscode folder contains launch configuration and tasks you configure in 19 | # VS Code which you may wish to be included in version control, so this line 20 | # is commented out by default. 21 | #.vscode/ 22 | .vscode/launch.json 23 | .vscode/settings.json 24 | 25 | # Flutter/Dart/Pub related 26 | **/doc/api/ 27 | **/ios/Flutter/.last_build_id 28 | .dart_tool/ 29 | .flutter-plugins 30 | .flutter-plugins-dependencies 31 | .packages 32 | .pub-cache/ 33 | .pub/ 34 | /build/ 35 | /coverage/ 36 | 37 | # Environment variables related 38 | assets/json/env/ 39 | 40 | # Web related 41 | lib/generated_plugin_registrant.dart 42 | 43 | # Symbolication related 44 | app.*.symbols 45 | 46 | # Obfuscation related 47 | app.*.map.json 48 | 49 | # Android Studio will place build artifacts here 50 | /android/app/debug 51 | /android/app/profile 52 | /android/app/release 53 | 54 | # Others 55 | test.http 56 | .fvm/flutter_sdk -------------------------------------------------------------------------------- /lib/widgets/loading.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_spinkit/flutter_spinkit.dart'; 3 | 4 | /// プライマリカラーの SpinkitCircle を表示する 5 | class PrimarySpinkitCircle extends StatelessWidget { 6 | const PrimarySpinkitCircle({ 7 | super.key, 8 | this.size = 48, 9 | }); 10 | 11 | final double size; 12 | @override 13 | Widget build(BuildContext context) { 14 | return SpinKitCircle( 15 | size: size, 16 | color: Theme.of(context).colorScheme.primary, 17 | ); 18 | } 19 | } 20 | 21 | /// 二度押しを防止したいときなどの重ねるローディングウィジェット 22 | class OverlayLoadingWidget extends StatelessWidget { 23 | const OverlayLoadingWidget({ 24 | super.key, 25 | this.backgroundColor = Colors.black26, 26 | }); 27 | 28 | final Color backgroundColor; 29 | 30 | @override 31 | Widget build(BuildContext context) { 32 | return ColoredBox( 33 | color: backgroundColor, 34 | child: const SizedBox( 35 | width: double.infinity, 36 | height: double.infinity, 37 | child: Center(child: PrimarySpinkitCircle(size: 48)), 38 | ), 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: flutter_github_search 2 | description: A new Flutter project. 3 | 4 | publish_to: 'none' 5 | 6 | version: 1.0.0+1 7 | 8 | environment: 9 | sdk: '>=2.17.0 <3.0.0' 10 | 11 | dependencies: 12 | cached_network_image: 13 | connectivity_plus: 2.3.0 14 | cookie_jar: 15 | cupertino_icons: 16 | dio: 17 | dio_cookie_manager: 18 | flutter: 19 | sdk: flutter 20 | flutter_hooks: 21 | flutter_localizations: 22 | sdk: flutter 23 | flutter_spinkit: 24 | font_awesome_flutter: 25 | freezed: 26 | freezed_annotation: 27 | gap: 28 | go_router: 29 | hooks_riverpod: 2.0.0-dev.8 30 | intl: 31 | json_annotation: 32 | package_info_plus: 33 | path_provider: 34 | shared_preferences: 35 | state_notifier: 36 | url_launcher: 37 | 38 | dev_dependencies: 39 | build_runner: 40 | fake_async: 41 | flutter_launcher_icons: 42 | flutter_lints: 43 | flutter_test: 44 | sdk: flutter 45 | json_serializable: ^6.1.6 46 | mockito: ^5.1.0 47 | 48 | flutter: 49 | uses-material-design: true 50 | assets: 51 | - assets/json/env/ 52 | - assets/json/mock/repos/ 53 | - assets/json/mock/search/ 54 | -------------------------------------------------------------------------------- /lib/pages/first/first_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_hooks/flutter_hooks.dart'; 3 | import 'package:gap/gap.dart'; 4 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 5 | 6 | class FirstPage extends HookConsumerWidget { 7 | const FirstPage({super.key}); 8 | 9 | static const path = '/first/'; 10 | static const name = 'FirstPage'; 11 | 12 | @override 13 | Widget build(BuildContext context, WidgetRef ref) { 14 | final counter = useState(0); 15 | return Scaffold( 16 | appBar: AppBar(), 17 | body: Center( 18 | child: Column( 19 | mainAxisAlignment: MainAxisAlignment.center, 20 | children: [ 21 | const Text(name), 22 | const Gap(8), 23 | const Text('You have pushed the button this many time:'), 24 | Text('${counter.value}'), 25 | ], 26 | ), 27 | ), 28 | floatingActionButton: FloatingActionButton( 29 | onPressed: () { 30 | counter.value++; 31 | }, 32 | tooltip: 'Increment', 33 | child: const Icon(Icons.add), 34 | ), 35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /lib/models/response_data/search_repo_response/search_repo_response.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../../repo/repo.dart'; 4 | import '../base_response_data/base_response_data.dart'; 5 | 6 | part 'search_repo_response.freezed.dart'; 7 | part 'search_repo_response.g.dart'; 8 | 9 | @freezed 10 | class SearchRepoResponse with _$SearchRepoResponse { 11 | const factory SearchRepoResponse({ 12 | @Default(true) bool success, 13 | @Default('') String message, 14 | @Default(0) @JsonKey(name: 'total_count') int totalCount, 15 | @Default(false) @JsonKey(name: 'incomplete_results') bool incompleteResults, 16 | @Default([]) @JsonKey(name: 'items') List repos, 17 | }) = _SearchRepoResponse; 18 | 19 | factory SearchRepoResponse.fromJson(Map json) => 20 | _$SearchRepoResponseFromJson(json); 21 | 22 | factory SearchRepoResponse.fromBaseResponseData(BaseResponseData baseResponseData) => 23 | SearchRepoResponse.fromJson( 24 | { 25 | 'success': baseResponseData.success, 26 | 'message': baseResponseData.message, 27 | ...baseResponseData.data, 28 | }, 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /lib/widgets/main_stacked_pages_navigator.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 3 | 4 | import '../pages/main/main_page.dart'; 5 | import '../pages/not_found/not_found_page.dart'; 6 | import '../route/bottom_tabs.dart'; 7 | import '../route/router.dart'; 8 | 9 | class MainStackedPagesNavigator extends HookConsumerWidget { 10 | const MainStackedPagesNavigator({ 11 | super.key, 12 | required this.bottomTab, 13 | }); 14 | 15 | final BottomTab bottomTab; 16 | 17 | @override 18 | Widget build(BuildContext context, WidgetRef ref) { 19 | return Navigator( 20 | key: bottomTab.key, 21 | initialRoute: MainPage.path, 22 | observers: [ 23 | HeroController(), 24 | ], 25 | // MainPage の StackedPages 上での Navigation の設定 26 | onGenerateRoute: (routeSettings) => ref 27 | .watch(routerProvider) 28 | .onGenerateRoute(routeSettings, bottomNavigationPath: bottomTab.path), 29 | onUnknownRoute: (settings) { 30 | final route = MaterialPageRoute( 31 | settings: settings, 32 | builder: (context) => const NotFoundPage(), 33 | ); 34 | return route; 35 | }, 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/models/repo/repo.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'repo.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | _$_Repo _$$_RepoFromJson(Map json) => _$_Repo( 10 | id: json['id'] as int, 11 | name: json['name'] as String, 12 | owner: Owner.fromJson(json['owner'] as Map), 13 | htmlUrl: json['html_url'] as String, 14 | description: json['description'] as String? ?? '', 15 | updatedAt: DateTime.parse(json['updated_at'] as String), 16 | starGazersCount: json['stargazers_count'] as int? ?? 0, 17 | forksCount: json['forks_count'] as int? ?? 0, 18 | ); 19 | 20 | Map _$$_RepoToJson(_$_Repo instance) => { 21 | 'id': instance.id, 22 | 'name': instance.name, 23 | 'owner': instance.owner, 24 | 'html_url': instance.htmlUrl, 25 | 'description': instance.description, 26 | 'updated_at': instance.updatedAt.toIso8601String(), 27 | 'stargazers_count': instance.starGazersCount, 28 | 'forks_count': instance.forksCount, 29 | }; 30 | -------------------------------------------------------------------------------- /lib/models/response_data/search_repo_response/search_repo_response.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'search_repo_response.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | _$_SearchRepoResponse _$$_SearchRepoResponseFromJson( 10 | Map json) => 11 | _$_SearchRepoResponse( 12 | success: json['success'] as bool? ?? true, 13 | message: json['message'] as String? ?? '', 14 | totalCount: json['total_count'] as int? ?? 0, 15 | incompleteResults: json['incomplete_results'] as bool? ?? false, 16 | repos: (json['items'] as List?) 17 | ?.map((e) => Repo.fromJson(e as Map)) 18 | .toList() ?? 19 | const [], 20 | ); 21 | 22 | Map _$$_SearchRepoResponseToJson( 23 | _$_SearchRepoResponse instance) => 24 | { 25 | 'success': instance.success, 26 | 'message': instance.message, 27 | 'total_count': instance.totalCount, 28 | 'incomplete_results': instance.incompleteResults, 29 | 'items': instance.repos, 30 | }; 31 | -------------------------------------------------------------------------------- /lib/utils/dio_interceptors/request_interceptor.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:dio/dio.dart'; 4 | import 'package:flutter/material.dart'; 5 | 6 | /// HTTP リクエストをインターセプトして行いたい処理などを行う 7 | class RequestInterceptor extends Interceptor { 8 | @override 9 | void onRequest( 10 | RequestOptions options, 11 | RequestInterceptorHandler handler, 12 | ) { 13 | debugPrint('*** Request ***'); 14 | _printCurlCommand(options); 15 | super.onRequest(options, handler); 16 | } 17 | 18 | /// Curl コマンドを出力する 19 | void _printCurlCommand(RequestOptions options) { 20 | final stringBuffer = StringBuffer(); 21 | var query = ''; 22 | if (options.method == 'GET' && options.queryParameters.isNotEmpty) { 23 | query = 24 | '?${options.queryParameters.entries.map((e) => '${e.key}=${e.value}').toList().join('&')}'; 25 | } 26 | final requestUrl = '${options.baseUrl}${options.path}$query'; 27 | stringBuffer.write('curl --request ${options.method} \'$requestUrl\''); 28 | for (final key in options.headers.keys) { 29 | stringBuffer.write(' -H \'$key: ${options.headers[key]}\''); 30 | } 31 | if (options.data != null && options.data is Map) { 32 | stringBuffer.write(' --data-binary \'${jsonEncode(options.data)}\''); 33 | } 34 | // SSL 証明書のエラーを無視する 35 | stringBuffer.write(' --insecure'); 36 | debugPrint(stringBuffer.toString()); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "TEST", 6 | "type": "shell", 7 | "dependsOrder": "sequence", 8 | "dependsOn": [ 9 | "import all Dart files", 10 | "flutter test --coverage", 11 | "generate test coverage html", 12 | "open coverage result html" 13 | ] 14 | }, 15 | { 16 | "label": "import all Dart files", 17 | "type": "shell", 18 | "command": "$(pwd)/scripts/import_all_for_coverage.sh", 19 | "presentation": { 20 | "reveal": "always", 21 | "panel": "shared" 22 | } 23 | }, 24 | { 25 | "label": "flutter test --coverage", 26 | "type": "flutter", 27 | "command": "flutter", 28 | "args": ["test", "--coverage"], 29 | "presentation": { 30 | "reveal": "always", 31 | "panel": "shared" 32 | } 33 | }, 34 | { 35 | "label": "generate test coverage html", 36 | "type": "shell", 37 | "command": "genhtml coverage/lcov.info -o coverage/html", 38 | "presentation": { 39 | "reveal": "never", 40 | "panel": "shared" 41 | } 42 | }, 43 | { 44 | "label": "open coverage result html", 45 | "type": "shell", 46 | "command": "open coverage/html/index.html", 47 | "presentation": { 48 | "reveal": "never", 49 | "panel": "shared" 50 | } 51 | } 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /lib/utils/api.dart: -------------------------------------------------------------------------------- 1 | import '../constants/map.dart'; 2 | import '../constants/string.dart'; 3 | 4 | /// 型不定 (dynamic) なレスポンスデータを Map に変換する。 5 | Map toResponseJson(dynamic data) { 6 | if (data == null) { 7 | return emptyMap; 8 | } 9 | if (data is List) { 10 | // リストの場合は適当なキー名として 'items' をつける 11 | return {'items': data}; 12 | } 13 | if (data is Map) { 14 | return data as Map; 15 | } 16 | // リストでもマップでもない場合は存在するのか?? 17 | // 存在しない想定でとりあえず空のマップを返すことにした。 18 | return emptyMap; 19 | } 20 | 21 | /// HTTP 通信でのエラーの種別の列挙 22 | /// いまは ConnectivityInterceptor で onRequest をインターセプトして 23 | /// ネットワーク接続を確認したときに、接続がない場合の networkNotConnected しかない。 24 | /// 必要に応じて増やす。 25 | enum ErrorCode { 26 | networkNotConnected, 27 | } 28 | 29 | /// GET /search/repository API や GET /repos/{owner}/{repo}/issues API 30 | /// をコールする上での条件や結果に関するエラー 31 | enum FetchResponseError { 32 | /// 正常 33 | none(message: ''), 34 | 35 | /// 検索ワード 36 | emptyQ(message: emptyQMessage), 37 | 38 | /// ページ番号が正しくない 39 | pageNotValid(message: 'ページ番号が正しくありません。'), 40 | 41 | /// 1 ページあたりの番号が正しくない 42 | perPageNotValid(message: '1 ページあたりの件数が正しくありません。'), 43 | 44 | /// API エラー 45 | apiError(message: 'リポジトリの検索に失敗しました。'), 46 | 47 | /// その他 48 | other(message: 'エラーが発生しました。検索条件を見直してください。'); 49 | 50 | const FetchResponseError({required this.message}); 51 | 52 | final String message; 53 | } 54 | -------------------------------------------------------------------------------- /lib/repositories/search_repo.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/dio.dart'; 2 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 3 | 4 | import '../../services/api_client.dart'; 5 | import '../models/response_data/search_repo_response/search_repo_response.dart'; 6 | import '../utils/exceptions/base.dart'; 7 | 8 | final searchRepoRepositoryProvider = Provider.autoDispose( 9 | (ref) => RepoRepository(client: ref.read(apiClientProvider)), 10 | ); 11 | 12 | class RepoRepository { 13 | RepoRepository({ 14 | required ApiClient client, 15 | }) : _client = client; 16 | final ApiClient _client; 17 | 18 | /// 入力したキーワードで GET /search/repositories API をコールして、 19 | /// ヒットする GitHub リポジトリ一覧を含む SearchRepoResponse を返す。 20 | Future fetchRepositories({ 21 | required String q, 22 | int page = 1, 23 | int perPage = 10, 24 | }) async { 25 | final responseResult = await _client.get( 26 | '/search/repositories', 27 | queryParameters: { 28 | 'q': q, 29 | 'page': page, 30 | 'per_page': perPage, 31 | }, 32 | options: Options( 33 | headers: { 34 | 'Accept': 'application/vnd.github.v3+json', 35 | }, 36 | ), 37 | ); 38 | return responseResult.when( 39 | success: SearchRepoResponse.fromBaseResponseData, 40 | failure: (message) => throw AppException(message: message), 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/widgets/repo/text_field.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 3 | 4 | import '../../constants/number.dart'; 5 | import '../../providers/repo/search_repo.dart'; 6 | import '../../utils/timer.dart'; 7 | 8 | class RepoPageTextField extends StatefulHookConsumerWidget { 9 | const RepoPageTextField({super.key}); 10 | 11 | @override 12 | ConsumerState createState() => _RepoPageTextFieldState(); 13 | } 14 | 15 | class _RepoPageTextFieldState extends ConsumerState { 16 | late TextEditingController _textEditingController; 17 | final debounce = Debounce(duration: minSearchApiCallPeriodDuration); 18 | 19 | @override 20 | void initState() { 21 | _textEditingController = TextEditingController(); 22 | super.initState(); 23 | } 24 | 25 | @override 26 | void dispose() { 27 | _textEditingController.dispose(); 28 | super.dispose(); 29 | } 30 | 31 | @override 32 | Widget build(BuildContext context) { 33 | return TextField( 34 | controller: _textEditingController, 35 | onChanged: (q) => debounce.run( 36 | () => ref.read(searchRepoStateNotifierProvider.notifier).updateSearchWord(q), 37 | ), 38 | maxLines: 1, 39 | decoration: const InputDecoration( 40 | border: OutlineInputBorder(), 41 | prefixIcon: Icon(Icons.search), 42 | contentPadding: EdgeInsets.symmetric(vertical: 0, horizontal: 16), 43 | ), 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /lib/providers/common/dio.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/adapter.dart'; 2 | import 'package:dio/dio.dart'; 3 | import 'package:flutter/foundation.dart'; 4 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 5 | 6 | import '../../constants/number.dart'; 7 | import '../../constants/string.dart'; 8 | import '../../utils/dio_interceptors/connectivity_interceptor.dart'; 9 | import '../../utils/dio_interceptors/header_interceptor.dart'; 10 | import '../../utils/dio_interceptors/mock_interceptor.dart'; 11 | import '../../utils/dio_interceptors/request_interceptor.dart'; 12 | import '../../utils/dio_interceptors/response_interceptor.dart'; 13 | import 'use_mock.dart'; 14 | 15 | /// Dio のインスタンスを各種設定を済ませた状態で提供するプロバイダ 16 | final dioProvider = Provider((ref) { 17 | final dio = Dio(); 18 | dio.httpClientAdapter = DefaultHttpClientAdapter(); 19 | dio.options = BaseOptions( 20 | baseUrl: apiBaseURL, 21 | connectTimeout: connectionTimeoutMilliSeconds, 22 | receiveTimeout: receiveTimeoutMilliSeconds, 23 | validateStatus: (_) => true, 24 | ); 25 | dio.interceptors.addAll([ 26 | HeaderInterceptor(), 27 | // CookieManager(ref.read(cookieJarProvider)), 28 | // デバッグモードでは RequestInterceptor を追加 29 | if (kDebugMode) RequestInterceptor(), 30 | // デバッグモードでは ResponseInterceptor を追加 31 | if (kDebugMode) ResponseInterceptor(), 32 | // モックで動作させる場合は MockInterceptor を追加 33 | // モックで動作させない場合のみ ConnectivityInterceptor を追加 34 | if (ref.watch(useMockProvider)) MockInterceptor() else ConnectivityInterceptor(), 35 | ]); 36 | return dio; 37 | }); 38 | -------------------------------------------------------------------------------- /lib/utils/exceptions/api_exceptions.dart: -------------------------------------------------------------------------------- 1 | import 'base.dart'; 2 | 3 | /// HTTP 通信時に使用する例外型。 4 | class ApiException extends AppException implements Exception { 5 | const ApiException({ 6 | super.code, 7 | super.message, 8 | super.defaultMessage = 'サーバとの通信に失敗しました。', 9 | }); 10 | } 11 | 12 | /// HTTP リクエストで 401 が発生した場合の例外 13 | class UnauthorizedException extends ApiException { 14 | const UnauthorizedException({super.message}) 15 | : super( 16 | code: '401', 17 | defaultMessage: '認証されていません。', 18 | ); 19 | } 20 | 21 | /// HTTP リクエストで 403 が発生した場合の例外 22 | class ForbiddenException extends ApiException { 23 | const ForbiddenException({super.message}) 24 | : super( 25 | code: '403', 26 | defaultMessage: '指定した操作を行う権限がありません。', 27 | ); 28 | } 29 | 30 | /// HTTP リクエストで 404 が発生した場合の例外 31 | class ApiNotFoundException extends ApiException { 32 | const ApiNotFoundException({super.message}) 33 | : super( 34 | code: '404', 35 | defaultMessage: 'リクエストした API が見つかりませんでした。', 36 | ); 37 | } 38 | 39 | /// HTTP リクエストがタイムアウトした場合の例外 40 | class ApiTimeoutException extends ApiException { 41 | const ApiTimeoutException({super.message}) 42 | : super( 43 | defaultMessage: 'API 通信がタイムアウトしました。' 44 | '通信環境をご確認のうえ、再度実行してください。', 45 | ); 46 | } 47 | 48 | /// HTTP リクエスト時のネットワーク接続に問題がある場合の例外 49 | class NetworkNotConnectedException extends ApiException { 50 | const NetworkNotConnectedException({super.message}) 51 | : super( 52 | defaultMessage: 'ネットワーク接続がありません。', 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /lib/widgets/root.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 3 | 4 | import '../../utils/provider_scope.dart'; 5 | 6 | /// runApp() の引数にするべき 7 | /// アプリケーションのルートのウィジェット 8 | class RootWidget extends StatefulWidget { 9 | const RootWidget({ 10 | super.key, 11 | required this.widget, 12 | required this.overrides, 13 | }); 14 | 15 | final Widget widget; 16 | final List overrides; 17 | 18 | static Future restart(BuildContext context) async { 19 | // アプリの再起動に際して、ProviderScope の overrides を再度やり直す 20 | final overrides = await providerScopeOverrides; 21 | // ignore: use_build_context_synchronously 22 | context.findAncestorStateOfType<_RootWidgetState>()!.restart(overrides); 23 | } 24 | 25 | @override 26 | State createState() => _RootWidgetState(); 27 | } 28 | 29 | class _RootWidgetState extends State { 30 | Key _key = UniqueKey(); 31 | List _overrides = []; 32 | 33 | void restart(List overrides) { 34 | setState(() { 35 | _key = UniqueKey(); 36 | _overrides = overrides; 37 | }); 38 | } 39 | 40 | @override 41 | Widget build(BuildContext context) { 42 | return KeyedSubtree( 43 | key: _key, 44 | child: ProviderScope( 45 | // ProviderScope の overrides したい Provider やその値を列挙する。 46 | // 起動時に一回インスタンス化したキャッシュを使いませせるようにすることで、 47 | // それ以降 await なしでアクセスしたいときなどに便利。 48 | overrides: _overrides.isEmpty ? widget.overrides : _overrides, 49 | child: widget.widget, 50 | ), 51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /test/pages/home/home_page_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_github_search/pages/first/first_page.dart'; 3 | import 'package:flutter_github_search/pages/home/home_page.dart'; 4 | import 'package:flutter_github_search/pages/second/second_page.dart'; 5 | import 'package:flutter_test/flutter_test.dart'; 6 | 7 | import '../../widgets/test_scaffold_wrapper.dart'; 8 | 9 | void main() { 10 | testWidgets('${HomePage.name} のテスト', (tester) async { 11 | await tester.pumpWidget( 12 | const TestScaffoldWrapper( 13 | child: HomePage(), 14 | ), 15 | ); 16 | 17 | // Go to FirstPage ボタンを押す 18 | await tester.tap(find.text('Go to ${FirstPage.name}')); 19 | 20 | // しばらく待つ 21 | await tester.pumpAndSettle(); 22 | 23 | // FirstPage に遷移していることを確認する 24 | expect(find.byType(FirstPage), findsOneWidget); 25 | 26 | // 戻る 27 | await tester.pageBack(); 28 | 29 | // しばらく待つ 30 | await tester.pumpAndSettle(); 31 | 32 | // Go to SecondPage ボタンを押す 33 | await tester.tap(find.text('Go to ${SecondPage.name}')); 34 | 35 | // しばらく待つ 36 | await tester.pumpAndSettle(); 37 | 38 | // SecondPage に遷移していることを確認する 39 | expect(find.byType(SecondPage), findsOneWidget); 40 | 41 | // 戻る 42 | await tester.pageBack(); 43 | 44 | // しばらく待つ 45 | await tester.pumpAndSettle(); 46 | 47 | // Show SnackBar ボタンを押す 48 | await tester.tap(find.byKey(const ValueKey('SnackBar'))); 49 | 50 | // 待つ 51 | await tester.pump(); 52 | 53 | // スナックバーが表示されていることを確認する 54 | expect(find.text('A SnackBar is shown.'), findsOneWidget); 55 | }); 56 | } 57 | -------------------------------------------------------------------------------- /ios/Podfile: -------------------------------------------------------------------------------- 1 | # Uncomment this line to define a global platform for your project 2 | platform :ios, '12.0' 3 | 4 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. 5 | ENV['COCOAPODS_DISABLE_STATS'] = 'true' 6 | 7 | project 'Runner', { 8 | 'Debug' => :debug, 9 | 'Profile' => :release, 10 | 'Release' => :release, 11 | } 12 | 13 | def flutter_root 14 | generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) 15 | unless File.exist?(generated_xcode_build_settings_path) 16 | raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" 17 | end 18 | 19 | File.foreach(generated_xcode_build_settings_path) do |line| 20 | matches = line.match(/FLUTTER_ROOT\=(.*)/) 21 | return matches[1].strip if matches 22 | end 23 | raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" 24 | end 25 | 26 | require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) 27 | 28 | flutter_ios_podfile_setup 29 | 30 | target 'Runner' do 31 | use_frameworks! 32 | use_modular_headers! 33 | 34 | flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) 35 | end 36 | 37 | post_install do |installer| 38 | installer.pods_project.targets.each do |target| 39 | flutter_additional_ios_build_settings(target) 40 | target.build_configurations.each do |config| 41 | config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.0' 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/services/navigation.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 3 | 4 | import '../providers/bottom_tab/bottom_tab.dart'; 5 | import '../route/bottom_tabs.dart'; 6 | import '../utils/route.dart'; 7 | 8 | final navigationServiceProvider = 9 | Provider.autoDispose((ref) => NavigationService(ref.read)); 10 | 11 | class NavigationService { 12 | NavigationService(this._read); 13 | 14 | final Reader _read; 15 | 16 | /// 現在アクティブな下タブに指定したパスのページを push する。 17 | Future pushOnCurrentTab({ 18 | required String path, 19 | required Map data, 20 | }) async => 21 | _read(bottomTabStateProvider) 22 | .key 23 | .currentState 24 | ?.pushNamed(path, arguments: RouteArguments(data)); 25 | 26 | /// 一度 MainPage まで画面を pop した上で、 27 | /// 指定したタブをアクティブにして、その上で指定したパスのページを push する。 28 | /// 指定したパスが MainPage のいずれかのページのパスと一致する場合には push せず、 29 | /// そのタブをアクティブにするだけで終わりにする。 30 | Future popUntilFirstRouteAndPushOnSpecifiedTab({ 31 | required BottomTab bottomTab, 32 | required String path, 33 | required Map data, 34 | }) async { 35 | final currentContext = _read(bottomTabStateProvider).key.currentContext; 36 | if (currentContext == null) { 37 | return; 38 | } 39 | Navigator.popUntil(currentContext, (route) => route.isFirst); 40 | _read(bottomTabStateProvider.notifier).update((state) => bottomTab); 41 | return _read(bottomTabStateProvider) 42 | .key 43 | .currentState 44 | ?.pushNamed(path, arguments: RouteArguments(data)); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /test/widgets/test_scaffold_wrapper.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_github_search/route/router.dart'; 3 | import 'package:flutter_github_search/services/scaffold_messenger.dart'; 4 | import 'package:flutter_github_search/widgets/root.dart'; 5 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 6 | 7 | /// アプリの 8 | /// main.dart > app.dart > scaffold_messenger_navigator.dart 9 | /// で記述・構成しているウィジェットツリーと同等の内容を 10 | /// ウィジェットテストでも提供するためのラッパーウィジェット。 11 | /// 主に検査したいウィジェットを child に指定する。 12 | /// 必要に応じて ProviderScope.overrides も指定する。 13 | class TestScaffoldWrapper extends StatelessWidget { 14 | const TestScaffoldWrapper({ 15 | super.key, 16 | this.child, 17 | this.overrides = const [], 18 | }); 19 | 20 | final Widget? child; 21 | final List overrides; 22 | 23 | @override 24 | Widget build(BuildContext context) { 25 | return RootWidget( 26 | overrides: overrides, 27 | widget: TestApp(child: child), 28 | ); 29 | } 30 | } 31 | 32 | class TestApp extends HookConsumerWidget { 33 | const TestApp({ 34 | super.key, 35 | this.child, 36 | }); 37 | 38 | final Widget? child; 39 | 40 | @override 41 | Widget build(BuildContext context, WidgetRef ref) { 42 | return MaterialApp( 43 | key: UniqueKey(), 44 | // navigatorObservers: [MockNavigatorObserver()], 45 | home: ScaffoldMessenger( 46 | key: ref.watch(scaffoldMessengerServiceProvider.select((c) => c.scaffoldMessengerKey)), 47 | child: Scaffold( 48 | body: child, 49 | ), 50 | ), 51 | onGenerateRoute: ref.watch(routerProvider).onGenerateRoute, 52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /lib/services/abstract_api_client.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/dio.dart'; 2 | 3 | import '../models/response_data/response_result/response_result.dart'; 4 | 5 | /// dio.dart の abstract class Dio の形式に沿った 6 | /// API クライアントの抽象クラス 7 | abstract class AbstractApiClient { 8 | Future get( 9 | String path, { 10 | Map queryParameters, 11 | Map header, 12 | Options options, 13 | CancelToken cancelToken, 14 | ProgressCallback onReceiveProgress, 15 | }); 16 | 17 | Future put( 18 | String path, { 19 | Map data, 20 | Map queryParameters, 21 | Map header, 22 | Options options, 23 | CancelToken cancelToken, 24 | ProgressCallback onSendProgress, 25 | ProgressCallback onReceiveProgress, 26 | }); 27 | 28 | Future patch( 29 | String path, { 30 | Map data, 31 | Map queryParameters, 32 | Map header, 33 | CancelToken cancelToken, 34 | ProgressCallback onReceiveProgress, 35 | }); 36 | 37 | Future post( 38 | String path, { 39 | Map data, 40 | Map queryParameters, 41 | Map header, 42 | Options options, 43 | CancelToken cancelToken, 44 | ProgressCallback onSendProgress, 45 | ProgressCallback onReceiveProgress, 46 | }); 47 | 48 | Future delete( 49 | String path, { 50 | Map data, 51 | Map queryParameters, 52 | Map header, 53 | Options options, 54 | CancelToken cancelToken, 55 | }); 56 | } 57 | -------------------------------------------------------------------------------- /lib/widgets/scaffold_messenger_navigator.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 3 | 4 | import '../pages/not_found/not_found_page.dart'; 5 | import '../providers/overlay_loading/overlay_loading.dart'; 6 | import '../route/router.dart'; 7 | import '../services/scaffold_messenger.dart'; 8 | import 'loading.dart'; 9 | 10 | /// Widget Tree の最上部で ScaffoldMessenger を含めるための Navigator ウィジェット。 11 | /// 目には見えないが、アプリケーション上の全てのページがこの Scaffold の上に載るので 12 | /// scaffoldMessengerServiceProvider でどこからでもスナックバーが表示できるようになっている。 13 | class ScaffoldMessengerNavigator extends HookConsumerWidget { 14 | const ScaffoldMessengerNavigator({super.key}); 15 | 16 | @override 17 | Widget build(BuildContext context, WidgetRef ref) { 18 | return ScaffoldMessenger( 19 | key: ref.watch(scaffoldMessengerServiceProvider.select((c) => c.scaffoldMessengerKey)), 20 | child: Scaffold( 21 | body: Stack( 22 | children: [ 23 | Navigator( 24 | key: ref.watch(scaffoldMessengerServiceProvider.select((c) => c.navigatorKey)), 25 | initialRoute: ref.watch(routerProvider).initialRoute, 26 | onGenerateRoute: ref.watch(routerProvider).onGenerateRoute, 27 | observers: const [], 28 | onUnknownRoute: (settings) { 29 | final route = MaterialPageRoute( 30 | settings: settings, 31 | builder: (context) => const NotFoundPage(), 32 | ); 33 | return route; 34 | }, 35 | ), 36 | if (ref.watch(overlayLoadingProvider)) const OverlayLoadingWidget(), 37 | ], 38 | ), 39 | ), 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 7 | 15 | 19 | 23 | 24 | 25 | 26 | 27 | 28 | 30 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /ios/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Flutter Github Search 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | flutter_github_search 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | $(FLUTTER_BUILD_NAME) 21 | CFBundleSignature 22 | ???? 23 | CFBundleVersion 24 | $(FLUTTER_BUILD_NUMBER) 25 | LSRequiresIPhoneOS 26 | 27 | UILaunchStoryboardName 28 | LaunchScreen 29 | UIMainStoryboardFile 30 | Main 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | UIInterfaceOrientationLandscapeLeft 35 | UIInterfaceOrientationLandscapeRight 36 | 37 | UISupportedInterfaceOrientations~ipad 38 | 39 | UIInterfaceOrientationPortrait 40 | UIInterfaceOrientationPortraitUpsideDown 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | 44 | UIViewControllerBasedStatusBarAppearance 45 | 46 | CADisableMinimumFrameDurationOnPhone 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /lib/route/routes.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 3 | 4 | import '../pages/first/first_page.dart'; 5 | import '../pages/home/home_page.dart'; 6 | import '../pages/issue/issue_page.dart'; 7 | import '../pages/main/main_page.dart'; 8 | import '../pages/not_found/not_found_page.dart'; 9 | import '../pages/repo/repo_page.dart'; 10 | import '../pages/second/second_page.dart'; 11 | import '../pages/todo/todo_page.dart'; 12 | import '../utils/route.dart'; 13 | 14 | /// テスト時に override できるように Provider でラップしておく 15 | final appRoutesProvider = Provider((_) => appRoutes); 16 | 17 | /// ページのパスと対応するビルダーメソッドをまとめた 18 | /// AppRoute インスタンス一覧 19 | final appRoutes = [ 20 | AppRoute( 21 | MainPage.path, 22 | (_, args) => const MainPage( 23 | key: ValueKey(MainPage.name), 24 | ), 25 | ), 26 | AppRoute( 27 | FirstPage.path, 28 | (_, args) => const FirstPage( 29 | key: ValueKey(FirstPage.name), 30 | ), 31 | ), 32 | AppRoute( 33 | SecondPage.path, 34 | (_, args) => const SecondPage( 35 | key: ValueKey(SecondPage.name), 36 | ), 37 | ), 38 | AppRoute( 39 | TodoPage.path, 40 | (_, args) => TodoPage.withArguments( 41 | key: const ValueKey(TodoPage.name), 42 | args: args, 43 | ), 44 | ), 45 | AppRoute( 46 | HomePage.path, 47 | (_, args) => const HomePage( 48 | key: ValueKey(HomePage.name), 49 | ), 50 | ), 51 | AppRoute( 52 | RepoPage.path, 53 | (_, args) => const RepoPage( 54 | key: ValueKey(RepoPage.name), 55 | ), 56 | ), 57 | AppRoute( 58 | IssuePage.path, 59 | (_, args) => const IssuePage( 60 | key: ValueKey(IssuePage.name), 61 | ), 62 | ), 63 | AppRoute( 64 | NotFoundPage.path, 65 | (_, __) => const NotFoundPage( 66 | key: ValueKey(NotFoundPage.name), 67 | ), 68 | ), 69 | ]; 70 | -------------------------------------------------------------------------------- /test/pages/issue/issue_page_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_github_search/pages/issue/issue_page.dart'; 3 | import 'package:flutter_github_search/providers/common/use_mock.dart'; 4 | import 'package:flutter_github_search/providers/issue/fetch_issue.dart'; 5 | import 'package:flutter_github_search/widgets/issue/issue_item.dart'; 6 | import 'package:flutter_spinkit/flutter_spinkit.dart'; 7 | import 'package:flutter_test/flutter_test.dart'; 8 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 9 | 10 | import '../../widgets/test_scaffold_wrapper.dart'; 11 | 12 | void main() { 13 | testWidgets('${IssuePage.name} のテスト', (tester) async { 14 | await tester.runAsync(() async { 15 | await tester.pumpWidget( 16 | TestScaffoldWrapper( 17 | overrides: [ 18 | issueOwnerNameProvider.overrideWithProvider( 19 | StateProvider.autoDispose((_) => 'ownerName'), 20 | ), 21 | issueRepoNameProvider.overrideWithProvider( 22 | StateProvider.autoDispose((_) => 'test-repository-name'), 23 | ), 24 | useMockProvider.overrideWithValue(true), 25 | ], 26 | child: const IssuePage(), 27 | ), 28 | ); 29 | 30 | // SpinkitCircle が表示されているのを確認する 31 | expect(find.byType(SpinKitCircle), findsOneWidget); 32 | 33 | // モックの HTTP レスポンスが返ってくるまでしばらく待つ 34 | await Future.delayed(const Duration(seconds: 2)); 35 | 36 | // ここで HTTP レスポンスに応じた Issue 一覧ウィジェットが描画されている想定 37 | await tester.pumpAndSettle(); 38 | 39 | // RefreshIndicator や ListView の存在を確かめる 40 | expect(find.byType(RefreshIndicator), findsOneWidget); 41 | expect(find.byType(ListView), findsOneWidget); 42 | 43 | // IssueItemWidget が所定の個数返っていることを確かめる 44 | expect(find.byType(IssueItemWidget), findsNWidgets(2)); 45 | }); 46 | }); 47 | } 48 | -------------------------------------------------------------------------------- /lib/pages/todo/todo_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 3 | 4 | import '../../models/route_arg/todo_page/todo_page.dart'; 5 | import '../../models/todo/todo.dart'; 6 | import '../../providers/todo/todo.dart'; 7 | import '../../utils/route.dart'; 8 | import '../../widgets/loading.dart'; 9 | 10 | /// freezed の Union/Sealed クラス的な使い方を工夫して、 11 | /// Todo インスタンスまたは対応する id のどちらかをルート引数として受け取り、 12 | /// Todo を表示するテスト用のページ。 13 | class TodoPage extends HookConsumerWidget { 14 | /// プライベートなコンストラクタ 15 | const TodoPage._({ 16 | required this.arg, 17 | super.key, 18 | }); 19 | 20 | /// ルート引数を渡す名前付きコンストラクタ 21 | /// いまは雑に Map にしているが、 22 | /// RouteArguments を総称型で書けば、ルート引数も型安全にできる。 23 | TodoPage.withArguments({ 24 | Key? key, 25 | required RouteArguments args, 26 | }) : this._( 27 | key: key, 28 | arg: args['arg'] as TodoPageRouteArgument, 29 | ); 30 | 31 | static const path = '/third/'; 32 | static const name = 'TodoPage'; 33 | 34 | final TodoPageRouteArgument arg; 35 | 36 | @override 37 | Widget build(BuildContext context, WidgetRef ref) { 38 | return Scaffold( 39 | body: Center( 40 | child: arg.when( 41 | instance: (todo) => TodoWidget(todo: todo), 42 | id: (id) => ref.watch(todoFutureProvider(id)).when( 43 | loading: () => const PrimarySpinkitCircle(), 44 | error: (_, __) => const SizedBox(), 45 | data: (todo) => TodoWidget(todo: todo), 46 | ), 47 | ), 48 | ), 49 | ); 50 | } 51 | } 52 | 53 | class TodoWidget extends StatelessWidget { 54 | const TodoWidget({super.key, required this.todo}); 55 | 56 | final Todo todo; 57 | 58 | @override 59 | Widget build(BuildContext context) { 60 | return ListTile( 61 | leading: Icon(todo.isDone ? Icons.check_box : Icons.check_box_outline_blank), 62 | title: Text(todo.title), 63 | ); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /lib/route/bottom_tabs.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 3 | 4 | import '../pages/home/home_page.dart'; 5 | import '../pages/issue/issue_page.dart'; 6 | import '../pages/repo/repo_page.dart'; 7 | 8 | /// MainPage の BottomNavigationBarItem に対応する内容一覧。 9 | /// さすがに override することはないと思われるので Provider は使用せず 10 | /// イミュータブルなグローバル変数にする。 11 | final bottomTabs = [ 12 | BottomTab._( 13 | index: 0, 14 | label: 'Home', 15 | path: HomePage.path, 16 | iconData: FontAwesomeIcons.house, 17 | key: GlobalKey(), 18 | ), 19 | BottomTab._( 20 | index: 1, 21 | label: 'Repos', 22 | path: RepoPage.path, 23 | iconData: FontAwesomeIcons.database, 24 | key: GlobalKey(), 25 | ), 26 | BottomTab._( 27 | index: 2, 28 | label: 'Issues', 29 | path: IssuePage.path, 30 | iconData: FontAwesomeIcons.circleCheck, 31 | key: GlobalKey(), 32 | ), 33 | ]; 34 | 35 | /// MainPage の BottomNavigationBar の内容 36 | class BottomTab { 37 | /// プライベートなコンストラクタ。 38 | /// このファイルの外ではインスタンス化しない。 39 | const BottomTab._({ 40 | required this.index, 41 | required this.label, 42 | required this.path, 43 | required this.iconData, 44 | required this.key, 45 | }); 46 | 47 | final int index; 48 | final String label; 49 | final String path; 50 | final IconData iconData; 51 | final GlobalKey key; 52 | 53 | /// インデックス番号を指定して対応する BottomTab を取得する。 54 | /// BottomTab は外でインスタンス化するつもりがないので static メソッドでよい。 55 | static BottomTab getByIndex(int index) => bottomTabs.firstWhere( 56 | (b) => b.index == index, 57 | orElse: () => bottomTabs.first, 58 | ); 59 | 60 | /// パス名 (e.g. /home/)を指定して対応する BottomTab を取得する。 61 | /// BottomTab は外でインスタンス化するつもりがないので static メソッドでよい。 62 | static BottomTab getByPath(String bottomTabPath) => bottomTabs.firstWhere( 63 | (b) => b.path == bottomTabPath, 64 | orElse: () => bottomTabs.first, 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /lib/services/scaffold_messenger.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 3 | 4 | import '../constants/snack_bar.dart'; 5 | import '../constants/string.dart'; 6 | import '../utils/extensions/string.dart'; 7 | 8 | final scaffoldMessengerServiceProvider = Provider.autoDispose((ref) => ScaffoldMessengerService()); 9 | 10 | /// ScaffoldMessenger 上でスナックバーやダイアログを表示するためのサービスクラス 11 | class ScaffoldMessengerService { 12 | final scaffoldMessengerKey = GlobalKey(); 13 | final navigatorKey = GlobalKey(); 14 | 15 | /// showDialog で指定したビルダー関数を返す。 16 | Future showDialogByBuilder({ 17 | required Widget Function(BuildContext) builder, 18 | bool barrierDismissible = true, 19 | }) { 20 | return showDialog( 21 | context: scaffoldMessengerKey.currentContext!, 22 | barrierDismissible: barrierDismissible, 23 | builder: builder, 24 | ); 25 | } 26 | 27 | /// スナックバーを表示する 28 | ScaffoldFeatureController showSnackBar( 29 | String message, { 30 | bool removeCurrentSnackBar = true, 31 | Duration duration = defaultSnackBarDuration, 32 | }) { 33 | final scaffoldMessengerState = scaffoldMessengerKey.currentState!; 34 | if (removeCurrentSnackBar) { 35 | scaffoldMessengerState.removeCurrentSnackBar(); 36 | } 37 | return scaffoldMessengerState.showSnackBar( 38 | SnackBar( 39 | content: Text(message), 40 | behavior: defaultSnackBarBehavior, 41 | duration: duration, 42 | ), 43 | ); 44 | } 45 | 46 | /// Exception 起点でスナックバーを表示する 47 | /// Dart の Exception 型の場合は toString() 冒頭を取り除いて差し支えのないメッセージに置換しておく。 48 | ScaffoldFeatureController showSnackBarByException(Exception e) { 49 | final message = e.toString().replaceAll('Exception: ', '').replaceAll('Exception', ''); 50 | return showSnackBar(message.ifIsEmpty(generalExceptionMessage)); 51 | } 52 | 53 | /// フォーカスを外す 54 | void unFocus() { 55 | FocusScope.of(scaffoldMessengerKey.currentContext!).unfocus(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /android/app/build.gradle: -------------------------------------------------------------------------------- 1 | def localProperties = new Properties() 2 | def localPropertiesFile = rootProject.file('local.properties') 3 | if (localPropertiesFile.exists()) { 4 | localPropertiesFile.withReader('UTF-8') { reader -> 5 | localProperties.load(reader) 6 | } 7 | } 8 | 9 | def flutterRoot = localProperties.getProperty('flutter.sdk') 10 | if (flutterRoot == null) { 11 | throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") 12 | } 13 | 14 | def flutterVersionCode = localProperties.getProperty('flutter.versionCode') 15 | if (flutterVersionCode == null) { 16 | flutterVersionCode = '1' 17 | } 18 | 19 | def flutterVersionName = localProperties.getProperty('flutter.versionName') 20 | if (flutterVersionName == null) { 21 | flutterVersionName = '1.0' 22 | } 23 | 24 | apply plugin: 'com.android.application' 25 | apply plugin: 'kotlin-android' 26 | apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" 27 | 28 | android { 29 | compileSdkVersion flutter.compileSdkVersion 30 | 31 | compileOptions { 32 | sourceCompatibility JavaVersion.VERSION_1_8 33 | targetCompatibility JavaVersion.VERSION_1_8 34 | } 35 | 36 | kotlinOptions { 37 | jvmTarget = '1.8' 38 | } 39 | 40 | sourceSets { 41 | main.java.srcDirs += 'src/main/kotlin' 42 | } 43 | 44 | defaultConfig { 45 | // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). 46 | applicationId "com.kosukesaigusa.flutterGitHubSearch.flutter_github_search" 47 | minSdkVersion flutter.minSdkVersion 48 | targetSdkVersion flutter.targetSdkVersion 49 | versionCode flutterVersionCode.toInteger() 50 | versionName flutterVersionName 51 | } 52 | 53 | buildTypes { 54 | release { 55 | // TODO: Add your own signing config for the release build. 56 | // Signing with the debug keys for now, so `flutter run --release` works. 57 | signingConfig signingConfigs.debug 58 | } 59 | } 60 | } 61 | 62 | flutter { 63 | source '../..' 64 | } 65 | 66 | dependencies { 67 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 68 | } 69 | -------------------------------------------------------------------------------- /lib/models/response_data/base_response_data/base_response_data.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../../../constants/map.dart'; 4 | import '../../../utils/api.dart'; 5 | import '../../json_converter.dart'; 6 | 7 | part 'base_response_data.freezed.dart'; 8 | part 'base_response_data.g.dart'; 9 | 10 | /// アプリケーションで取り扱う HTTP のレスポンスボディのベースとなる型 11 | /// success, message などのフィールドは共通で存在するもの(存在しなければデフォルト値)を、 12 | /// その他の値はすべて Map で data に格納して取り扱うことができるよう、 13 | /// HTTP のレスポンスボディは、ApiClient の返り値の時点ですべて 14 | /// ApiResponse.fromDynamic(responseData) で BaseResponseData 型のインスタンスに変換される。 15 | /// Repository 層では、この Map data に型を付けるよう 16 | /// FooResponseData.fromBaseResponseData(baseResponseData) のようなコンストラクタを定義する。 17 | @freezed 18 | class BaseResponseData with _$BaseResponseData { 19 | const factory BaseResponseData({ 20 | @Default(true) bool success, 21 | @Default('') String message, 22 | @Default(emptyMap) @BaseResponseDataConverter() Map data, 23 | }) = _BaseResponseData; 24 | 25 | factory BaseResponseData.fromJson(Map json) => _$BaseResponseDataFromJson(json); 26 | 27 | /// HTTP レスポンスのレスポンスデータの方は不定 (dynamic) なので、 28 | /// それらのレスポンスデータはすべてこの fromDynamic コンストラクタに渡して、 29 | /// dynamic を適当に Map に変更した上で、fromJson コンストラクタに渡して 30 | /// BaseResponseData インスタンスを生成して返す。 31 | factory BaseResponseData.fromDynamic(dynamic responseData) { 32 | final data = toResponseJson(responseData); 33 | final baseData = { 34 | 'success': data['success'] ?? true, 35 | 'message': data['message'] ?? '', 36 | }; 37 | // baseData と重複するキーは取り除く 38 | data.removeWhere((key, dynamic value) => baseData.containsKey(key)); 39 | final keys = data.keys; 40 | if (keys.isEmpty) { 41 | return BaseResponseData.fromJson(baseData); 42 | } 43 | if (keys.length == 1) { 44 | return BaseResponseData.fromJson({ 45 | ...baseData, 46 | 'data': data[data.keys.first], 47 | }); 48 | } 49 | return BaseResponseData.fromJson({ 50 | ...baseData, 51 | 'data': { 52 | for (final k in data.keys) ...{k: data[k]}, 53 | }, 54 | }); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /ios/Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - connectivity_plus (0.0.1): 3 | - Flutter 4 | - ReachabilitySwift 5 | - Flutter (1.0.0) 6 | - FMDB (2.7.5): 7 | - FMDB/standard (= 2.7.5) 8 | - FMDB/standard (2.7.5) 9 | - package_info_plus (0.4.5): 10 | - Flutter 11 | - path_provider_ios (0.0.1): 12 | - Flutter 13 | - ReachabilitySwift (5.0.0) 14 | - shared_preferences_ios (0.0.1): 15 | - Flutter 16 | - sqflite (0.0.2): 17 | - Flutter 18 | - FMDB (>= 2.7.5) 19 | - url_launcher_ios (0.0.1): 20 | - Flutter 21 | 22 | DEPENDENCIES: 23 | - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) 24 | - Flutter (from `Flutter`) 25 | - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) 26 | - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) 27 | - shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`) 28 | - sqflite (from `.symlinks/plugins/sqflite/ios`) 29 | - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) 30 | 31 | SPEC REPOS: 32 | trunk: 33 | - FMDB 34 | - ReachabilitySwift 35 | 36 | EXTERNAL SOURCES: 37 | connectivity_plus: 38 | :path: ".symlinks/plugins/connectivity_plus/ios" 39 | Flutter: 40 | :path: Flutter 41 | package_info_plus: 42 | :path: ".symlinks/plugins/package_info_plus/ios" 43 | path_provider_ios: 44 | :path: ".symlinks/plugins/path_provider_ios/ios" 45 | shared_preferences_ios: 46 | :path: ".symlinks/plugins/shared_preferences_ios/ios" 47 | sqflite: 48 | :path: ".symlinks/plugins/sqflite/ios" 49 | url_launcher_ios: 50 | :path: ".symlinks/plugins/url_launcher_ios/ios" 51 | 52 | SPEC CHECKSUMS: 53 | connectivity_plus: 413a8857dd5d9f1c399a39130850d02fe0feaf7e 54 | Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a 55 | FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a 56 | package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e 57 | path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 58 | ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 59 | shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad 60 | sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 61 | url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de 62 | 63 | PODFILE CHECKSUM: f10c0438b63bc24e6bbc207956dc27d16c4408f2 64 | 65 | COCOAPODS: 1.11.3 66 | -------------------------------------------------------------------------------- /lib/utils/dio_interceptors/mock_interceptor.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:dio/dio.dart'; 4 | import 'package:flutter/services.dart'; 5 | 6 | /// 実際のレスポンスの代わりに対応する JSON の Mock データを返す 7 | /// HTTP リクエストのインターセプタ。 8 | class MockInterceptor extends Interceptor { 9 | @override 10 | Future onRequest( 11 | RequestOptions options, 12 | RequestInterceptorHandler handler, 13 | ) async { 14 | try { 15 | // JSON ファイルを特定して読み込む。 16 | // {rootDir}/assets/json/mock/ 以下の階層に 17 | // API の URL パスに対応する JSON ファイルが保存されている想定。 18 | final jsonFilePath = _getJsonFilePathFromRequestOptions(options); 19 | final byteData = await rootBundle.load(jsonFilePath); 20 | 21 | // JSON ファイルの内容を UTF-8 デコードする 22 | final dynamic data = json.decode( 23 | utf8.decode( 24 | byteData.buffer.asUint8List( 25 | byteData.offsetInBytes, 26 | byteData.lengthInBytes, 27 | ), 28 | ), 29 | ); 30 | 31 | return handler.resolve( 32 | Response( 33 | data: data, 34 | // TODO: 期待されるステータスコードは API によって異なるはずだが 35 | // どのようにして指定できるか調査して対応する。 36 | statusCode: 200, 37 | requestOptions: options, 38 | ), 39 | ); 40 | } on Exception { 41 | return handler.resolve( 42 | Response( 43 | // data: null, 44 | statusCode: 404, 45 | requestOptions: options, 46 | ), 47 | ); 48 | } 49 | } 50 | 51 | /// HTTP リクエストのパスから、モックの JSON ファイルを特定する。 52 | /// GitHub API では、パスにリポジトリのオーナー名やリポジトリ名が含まれるため、 53 | /// 必要に応じてそれを正規表現で検証して適切な JSON ファイルを特定できるようにする。 54 | String _getJsonFilePathFromRequestOptions(RequestOptions options) { 55 | // RequestOptions.path は URL パスであって、apiBaseURL は含まれていない。 56 | var path = options.path; 57 | 58 | // 末尾スラッシュを削除する 59 | path = path.replaceFirst(RegExp(r'/$'), ''); 60 | 61 | // 正規表現による検証をここに列挙する 62 | if (listRepoIssuesRegExp.hasMatch(path)) { 63 | path = '/repos/issues'; 64 | } 65 | 66 | // JSON ファイルのパスを作成して返す 67 | return baseDir + path + extension; 68 | } 69 | 70 | static const baseDir = 'assets/json/mock'; 71 | static const extension = '.json'; 72 | 73 | /// List repository Issues API のパスの正規表現 74 | /// https://docs.github.com/en/rest/issues/issues#list-repository-issues 75 | static final listRepoIssuesRegExp = RegExp(r'/repos/[0-9a-zA-Z\-]+/[0-9a-zA-Z\-]+/issues'); 76 | } 77 | -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /lib/route/router.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 3 | 4 | import '../constants/map.dart'; 5 | import '../pages/not_found/not_found_page.dart'; 6 | import '../utils/bool.dart'; 7 | import '../utils/route.dart'; 8 | import 'routes.dart'; 9 | 10 | final routerProvider = Provider((ref) => Router(ref.read)); 11 | 12 | class Router { 13 | Router(this._read); 14 | 15 | final Reader _read; 16 | final initialRoute = '/'; 17 | 18 | Route onGenerateRoute(RouteSettings routeSettings, {String? bottomNavigationPath}) { 19 | var path = _path(routeSettings, bottomNavigationPath: bottomNavigationPath); 20 | debugPrint('***'); 21 | debugPrint('path: $path'); 22 | 23 | // path に ? がついている場合は、それ以降をクエリストリングとみなし、 24 | // 分割して `queryParams` というマップに追加する。 25 | // path は ? 以前の文字列で上書きしておく。 26 | // 現状 fullScreenDialog=true くらいしか使いみちはない。 27 | var queryParams = emptyMap; 28 | if (path.contains('?')) { 29 | queryParams = Uri.parse(path).queryParameters; 30 | path = path.split('?').first; 31 | } 32 | 33 | // ページに渡す引数の Map 34 | final data = (routeSettings.arguments as RouteArguments?)?.data ?? emptyMap; 35 | 36 | try { 37 | // appRoutes の各要素のパスに一致する AppRoute を見つけて 38 | // 遷移先の Widget の MaterialPageRoute を返す 39 | final appRoute = _read(appRoutesProvider).firstWhere( 40 | (appRoute) => appRoute.path == path, 41 | orElse: () => throw RouteNotFoundException(path), 42 | ); 43 | final route = MaterialPageRoute( 44 | settings: routeSettings, 45 | builder: (context) => appRoute.pageBuilder(context, RouteArguments(data)), 46 | fullscreenDialog: toBool(queryParams['fullScreenDialog'] ?? false), 47 | ); 48 | return route; 49 | } on RouteNotFoundException catch (e) { 50 | final route = MaterialPageRoute( 51 | settings: routeSettings, 52 | builder: (context) => NotFoundPage(exception: e), 53 | ); 54 | return route; 55 | } 56 | } 57 | 58 | /// onGenerateRoute と同じ引数を受けてパスを決定する。 59 | String _path(RouteSettings routeSettings, {String? bottomNavigationPath}) { 60 | final path = routeSettings.name; 61 | if (path == null) { 62 | return ''; 63 | } 64 | if (bottomNavigationPath?.isEmpty ?? true) { 65 | return path; 66 | } 67 | if (path == initialRoute) { 68 | return bottomNavigationPath!; 69 | } 70 | return path; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /lib/repositories/issue.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/dio.dart'; 2 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 3 | 4 | import '../../services/api_client.dart'; 5 | import '../models/response_data/issue_response/issue_response.dart'; 6 | import '../models/response_data/issues_response/issues_response.dart'; 7 | import '../providers/common/github_access_token.dart'; 8 | import '../utils/exceptions/base.dart'; 9 | 10 | final issueRepositoryProvider = Provider.autoDispose( 11 | (ref) => IssueRepository( 12 | client: ref.read(apiClientProvider), 13 | accessToken: ref.read(gitHubAccessTokenProvider), 14 | ), 15 | ); 16 | 17 | class IssueRepository { 18 | IssueRepository({ 19 | required ApiClient client, 20 | required String accessToken, 21 | }) : _client = client, 22 | _accessToken = accessToken; 23 | 24 | final ApiClient _client; 25 | final String _accessToken; 26 | 27 | /// GET /repos/{owner}/{repo}/issues API をコールして 28 | /// 指定した GitHub リポジトリの Issue 一覧を取得する。 29 | Future fetchIssues({ 30 | required String ownerName, 31 | required String repoName, 32 | int page = 1, 33 | int perPage = 10, 34 | }) async { 35 | final responseResult = await _client.get( 36 | '/repos/$ownerName/$repoName/issues', 37 | queryParameters: { 38 | 'page': page, 39 | 'per_page': perPage, 40 | }, 41 | options: Options( 42 | headers: { 43 | 'Accept': 'application/vnd.github.v3+json', 44 | }, 45 | ), 46 | ); 47 | return responseResult.when( 48 | success: IssuesResponse.fromBaseResponseData, 49 | failure: (message) => throw AppException(message: message), 50 | ); 51 | } 52 | 53 | /// POST /repos/{owner}/{repo}/issues API をコールして 54 | /// 指定した GitHub リポジトリに Issue を作成する。 55 | Future createIssue({ 56 | required String ownerName, 57 | required String repoName, 58 | required String title, 59 | required String body, 60 | }) async { 61 | final responseResult = await _client.post( 62 | '/repos/$ownerName/$repoName/issues', 63 | data: { 64 | 'title': title, 65 | 'body': body, 66 | }, 67 | options: Options( 68 | headers: { 69 | 'Accept': 'application/vnd.github.v3+json', 70 | 'Authorization': 'token $_accessToken', 71 | }, 72 | ), 73 | ); 74 | return responseResult.when( 75 | success: IssueResponse.fromBaseResponseData, 76 | failure: (message) => throw AppException(message: message), 77 | ); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /lib/services/shared_preferences.dart: -------------------------------------------------------------------------------- 1 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 2 | import 'package:shared_preferences/shared_preferences.dart'; 3 | 4 | /// SharedPreferencesKey で管理するデータの列挙 5 | enum SharedPreferencesKey { 6 | issueTitle, 7 | issueBody, 8 | } 9 | 10 | /// SharedPreferences のインスタンスのプロバイダ 11 | final sharedPreferencesProvider = Provider((_) => throw UnimplementedError()); 12 | 13 | /// SharedPreferences によるデータの読み書きをする 14 | /// サービスクラスを提供するプロバイダ。 15 | final sharedPreferencesServiceProvider = 16 | Provider((ref) => SharedPreferencesService(ref.read)); 17 | 18 | class SharedPreferencesService { 19 | SharedPreferencesService(this._read); 20 | final Reader _read; 21 | 22 | // /// int 型のキー・バリューを保存する 23 | // Future _getInt(SharedPreferencesKey key) async { 24 | // return _read(sharedPreferencesProvider).getInt(key.name) ?? 0; 25 | // } 26 | 27 | // /// String 型のキー・バリューを保存する 28 | // Future _getString(SharedPreferencesKey key) async { 29 | // return _read(sharedPreferencesProvider).getString(key.name) ?? ''; 30 | // } 31 | 32 | // /// String 型のキー・バリューを取得する 33 | // Future _getStringByStringKey(String stringKey) async { 34 | // return _read(sharedPreferencesProvider).getString(stringKey) ?? ''; 35 | // } 36 | 37 | // /// bool 型のキー・バリューを取得する 38 | // Future _getBool(SharedPreferencesKey key) async { 39 | // return _read(sharedPreferencesProvider).getBool(key.name) ?? false; 40 | // } 41 | 42 | // /// int 型のキー・バリューペアを保存する 43 | // Future _setInt(SharedPreferencesKey key, int value) async { 44 | // return _read(sharedPreferencesProvider).setInt(key.name, value); 45 | // } 46 | 47 | // /// String 型のキー・バリューペアを保存する 48 | // Future _setString(SharedPreferencesKey key, String value) async { 49 | // return _read(sharedPreferencesProvider).setString(key.name, value); 50 | // } 51 | 52 | // /// String 型のキー・バリューペアを保存する 53 | // Future _setStringByStringKey(String stringKey, String value) async { 54 | // return _read(sharedPreferencesProvider).setString(stringKey, value); 55 | // } 56 | 57 | // /// bool 型のキー・バリューペアを保存する 58 | // Future _setBool(SharedPreferencesKey key, bool value) async { 59 | // return _read(sharedPreferencesProvider).setBool(key.name, value); 60 | // } 61 | 62 | /// SharedPreferences に保存している特定のキーを消す 63 | Future removeByStringKey(String stringKey) async { 64 | return _read(sharedPreferencesProvider).remove(stringKey); 65 | } 66 | 67 | /// SharedPreferences に保存している値をすべて消す 68 | Future clearAll() async { 69 | return _read(sharedPreferencesProvider).clear(); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /test/pages/repo/repo_page_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_github_search/constants/string.dart'; 3 | import 'package:flutter_github_search/pages/repo/repo_page.dart'; 4 | import 'package:flutter_github_search/providers/common/use_mock.dart'; 5 | import 'package:flutter_github_search/providers/repo/search_repo.dart'; 6 | import 'package:flutter_github_search/widgets/repo/repo_item.dart'; 7 | import 'package:flutter_github_search/widgets/repo/text_field.dart'; 8 | import 'package:flutter_test/flutter_test.dart'; 9 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 10 | 11 | import '../../widgets/test_scaffold_wrapper.dart'; 12 | 13 | void main() { 14 | testWidgets('${RepoPage.name} のテスト', (tester) async { 15 | await tester.runAsync(() async { 16 | await tester.pumpWidget( 17 | TestScaffoldWrapper( 18 | overrides: [ 19 | useMockProvider.overrideWithValue(true), 20 | ], 21 | child: Stack( 22 | children: [ 23 | const RepoPage(), 24 | Consumer( 25 | builder: (context, ref, _) { 26 | return ElevatedButton( 27 | onPressed: () => ref 28 | .read(searchRepoStateNotifierProvider.notifier) 29 | .updateSearchWord('flutter'), 30 | child: const Text('Search by: flutter'), 31 | ); 32 | }, 33 | ), 34 | ], 35 | ), 36 | ), 37 | ); 38 | 39 | // RepoPageTextField と 40 | // 検索キーワードの入力を勧める旨が表示されているのを確認する 41 | expect(find.byType(RepoPageTextField), findsOneWidget); 42 | expect(find.text(emptyQMessage), findsOneWidget); 43 | 44 | // 検索キーワードを入力する。 45 | // 通常の方法では flutter test では Timer が動かないので、 46 | // その後に StateNotifierProvider から直接検索キーワードを更新する。 47 | await tester.enterText(find.byType(TextField), 'flutter'); 48 | await tester.tap(find.text('Search by: flutter')); 49 | 50 | // 検索ワードが更新されて GET /search/repositories API をコールされるまでの 51 | // 一連の操作などをしばらく待つ 52 | await Future.delayed(const Duration(seconds: 3)); 53 | 54 | // ここで HTTP レスポンスに応じた Issue 一覧ウィジェットが描画されている想定 55 | await tester.pumpAndSettle(); 56 | 57 | // RefreshIndicator や ListView の存在を確かめる 58 | expect(find.byType(ListView), findsOneWidget); 59 | 60 | // RepoItemWidget が所定の個数返っていることを確かめる 61 | expect(find.byType(RepoItemWidget), findsNWidgets(1)); 62 | 63 | // GestureDetector の onTap を発火させるために 64 | // 適当に RepoPage の Scaffold.appBar をタップする 65 | await tester.tap(find.byKey(RepoPage.gestureDetectorKey)); 66 | 67 | // TODO: フォーカスがテキストフィールドから外れていることを確認する... 68 | }); 69 | }); 70 | } 71 | -------------------------------------------------------------------------------- /lib/pages/main/main_page.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 5 | 6 | import '../../providers/bottom_tab/bottom_tab.dart'; 7 | import '../../route/bottom_tabs.dart'; 8 | import '../../widgets/main_stacked_pages_navigator.dart'; 9 | 10 | class MainPage extends StatefulHookConsumerWidget { 11 | const MainPage({super.key}); 12 | 13 | static const path = '/'; 14 | static const name = 'MainPage'; 15 | 16 | @override 17 | ConsumerState createState() => _MainPageState(); 18 | } 19 | 20 | class _MainPageState extends ConsumerState with WidgetsBindingObserver { 21 | @override 22 | void initState() { 23 | super.initState(); 24 | WidgetsBinding.instance.addObserver(this); 25 | // 必要な初期化処理を行う 26 | Future.wait([ 27 | // _initializePushNotification(), 28 | // _initializeDynamicLinks(), 29 | ]); 30 | } 31 | 32 | /// アプリのライフサイクルを監視する 33 | @override 34 | void didChangeAppLifecycleState(AppLifecycleState state) { 35 | debugPrint('***'); 36 | debugPrint('AppLifecycleState: ${state.name}'); 37 | } 38 | 39 | @override 40 | Widget build(BuildContext context) { 41 | return Scaffold( 42 | body: Stack( 43 | children: [ 44 | Scaffold( 45 | body: Stack( 46 | children: [for (final tab in bottomTabs) _buildStackedPages(tab)], 47 | ), 48 | bottomNavigationBar: BottomNavigationBar( 49 | type: BottomNavigationBarType.fixed, 50 | selectedItemColor: Theme.of(context).colorScheme.primary, 51 | onTap: _onTap, 52 | currentIndex: ref.watch(bottomTabStateProvider).index, 53 | items: bottomTabs 54 | .map( 55 | (b) => BottomNavigationBarItem( 56 | icon: Icon(b.iconData), 57 | label: b.label, 58 | ), 59 | ) 60 | .toList(), 61 | ), 62 | ), 63 | ], 64 | ), 65 | ); 66 | } 67 | 68 | /// BottomNavigationBarItem をタップしたときの挙動 69 | /// 現在表示している状態のタブをタップした場合は画面をすべて pop する。 70 | void _onTap(int index) { 71 | FocusScope.of(context).unfocus(); 72 | final bottomTab = BottomTab.getByIndex(index); 73 | final currentBottomTab = ref.watch(bottomTabStateProvider); 74 | if (bottomTab == currentBottomTab) { 75 | bottomTab.key.currentState!.popUntil((route) => route.isFirst); 76 | return; 77 | } 78 | ref.read(bottomTabStateProvider.notifier).update((state) => bottomTab); 79 | } 80 | 81 | /// MainPage の BottomNavigationBar で切り替える各画面 82 | Widget _buildStackedPages(BottomTab bottomTab) { 83 | final currentBottomTab = ref.watch(bottomTabStateProvider); 84 | return Offstage( 85 | offstage: bottomTab != currentBottomTab, 86 | child: TickerMode( 87 | enabled: bottomTab == currentBottomTab, 88 | child: MainStackedPagesNavigator(bottomTab: bottomTab), 89 | ), 90 | ); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "20x20", 5 | "idiom" : "iphone", 6 | "filename" : "Icon-App-20x20@2x.png", 7 | "scale" : "2x" 8 | }, 9 | { 10 | "size" : "20x20", 11 | "idiom" : "iphone", 12 | "filename" : "Icon-App-20x20@3x.png", 13 | "scale" : "3x" 14 | }, 15 | { 16 | "size" : "29x29", 17 | "idiom" : "iphone", 18 | "filename" : "Icon-App-29x29@1x.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "29x29", 23 | "idiom" : "iphone", 24 | "filename" : "Icon-App-29x29@2x.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "29x29", 29 | "idiom" : "iphone", 30 | "filename" : "Icon-App-29x29@3x.png", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "size" : "40x40", 35 | "idiom" : "iphone", 36 | "filename" : "Icon-App-40x40@2x.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "40x40", 41 | "idiom" : "iphone", 42 | "filename" : "Icon-App-40x40@3x.png", 43 | "scale" : "3x" 44 | }, 45 | { 46 | "size" : "60x60", 47 | "idiom" : "iphone", 48 | "filename" : "Icon-App-60x60@2x.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "60x60", 53 | "idiom" : "iphone", 54 | "filename" : "Icon-App-60x60@3x.png", 55 | "scale" : "3x" 56 | }, 57 | { 58 | "size" : "20x20", 59 | "idiom" : "ipad", 60 | "filename" : "Icon-App-20x20@1x.png", 61 | "scale" : "1x" 62 | }, 63 | { 64 | "size" : "20x20", 65 | "idiom" : "ipad", 66 | "filename" : "Icon-App-20x20@2x.png", 67 | "scale" : "2x" 68 | }, 69 | { 70 | "size" : "29x29", 71 | "idiom" : "ipad", 72 | "filename" : "Icon-App-29x29@1x.png", 73 | "scale" : "1x" 74 | }, 75 | { 76 | "size" : "29x29", 77 | "idiom" : "ipad", 78 | "filename" : "Icon-App-29x29@2x.png", 79 | "scale" : "2x" 80 | }, 81 | { 82 | "size" : "40x40", 83 | "idiom" : "ipad", 84 | "filename" : "Icon-App-40x40@1x.png", 85 | "scale" : "1x" 86 | }, 87 | { 88 | "size" : "40x40", 89 | "idiom" : "ipad", 90 | "filename" : "Icon-App-40x40@2x.png", 91 | "scale" : "2x" 92 | }, 93 | { 94 | "size" : "76x76", 95 | "idiom" : "ipad", 96 | "filename" : "Icon-App-76x76@1x.png", 97 | "scale" : "1x" 98 | }, 99 | { 100 | "size" : "76x76", 101 | "idiom" : "ipad", 102 | "filename" : "Icon-App-76x76@2x.png", 103 | "scale" : "2x" 104 | }, 105 | { 106 | "size" : "83.5x83.5", 107 | "idiom" : "ipad", 108 | "filename" : "Icon-App-83.5x83.5@2x.png", 109 | "scale" : "2x" 110 | }, 111 | { 112 | "size" : "1024x1024", 113 | "idiom" : "ios-marketing", 114 | "filename" : "Icon-App-1024x1024@1x.png", 115 | "scale" : "1x" 116 | } 117 | ], 118 | "info" : { 119 | "version" : 1, 120 | "author" : "xcode" 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /lib/providers/issue/create_issue_dialog.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 3 | 4 | import '../../constants/snack_bar.dart'; 5 | import '../../repositories/issue.dart'; 6 | import '../../utils/exceptions/api_exceptions.dart'; 7 | import '../../utils/exceptions/base.dart'; 8 | import 'create_issue_dialog_state.dart'; 9 | import 'fetch_issue.dart'; 10 | 11 | /// Issue を作成するモーダル画面内の状態を保持・操作する 12 | /// StateNotifier を提供するプロバイダ。 13 | final createIssueDialogStateNotifierProvider = 14 | StateNotifierProvider.autoDispose( 15 | (ref) => CreateIssueDialogStateNotifier(ref.read), 16 | ); 17 | 18 | class CreateIssueDialogStateNotifier extends StateNotifier { 19 | CreateIssueDialogStateNotifier(this._read) : super(const CreateIssueDialogState()); 20 | 21 | final Reader _read; 22 | final scaffoldMessengerKey = GlobalKey(); 23 | 24 | final titleTextEditingController = TextEditingController(); 25 | final bodyTextEditingController = TextEditingController(); 26 | 27 | /// Issue を作成する 28 | Future createIssueDialog() async { 29 | if (!_validateBeforeSubmitting()) { 30 | return; 31 | } 32 | if (state.sending) { 33 | return; 34 | } 35 | try { 36 | state = state.copyWith(sending: true); 37 | await _read(issueRepositoryProvider).createIssue( 38 | ownerName: _read(issueOwnerNameProvider), 39 | repoName: _read(issueRepoNameProvider), 40 | title: titleTextEditingController.value.text, 41 | body: bodyTextEditingController.value.text, 42 | ); 43 | } on ApiException { 44 | rethrow; 45 | } on AppException { 46 | rethrow; 47 | } on Exception { 48 | rethrow; 49 | } finally { 50 | state = state.copyWith(sending: false); 51 | } 52 | } 53 | 54 | /// 「作成する」ボタンを押す前に入力内容を確認する。 55 | /// 不正な場合はスナックバーをアラートダイアログ上に表示する。 56 | bool _validateBeforeSubmitting() { 57 | if (titleTextEditingController.value.text.isEmpty) { 58 | showSnackBarOnDialog('タイトルを入力してください。'); 59 | return false; 60 | } 61 | if (bodyTextEditingController.value.text.isEmpty) { 62 | showSnackBarOnDialog('内容を入力してください。'); 63 | return false; 64 | } 65 | return true; 66 | } 67 | 68 | /// ダイアログ上でスナックバーを表示する 69 | ScaffoldFeatureController showSnackBarOnDialog( 70 | String message, { 71 | bool removeCurrentSnackBar = true, 72 | Duration duration = defaultSnackBarDuration, 73 | }) { 74 | final scaffoldMessengerState = scaffoldMessengerKey.currentState!; 75 | if (removeCurrentSnackBar) { 76 | scaffoldMessengerState.removeCurrentSnackBar(); 77 | } 78 | return scaffoldMessengerState.showSnackBar( 79 | SnackBar( 80 | content: Text(message), 81 | behavior: defaultSnackBarBehavior, 82 | duration: duration, 83 | ), 84 | ); 85 | } 86 | 87 | @override 88 | void dispose() { 89 | // TODO: SharedPreferences に下書きを保存する 90 | titleTextEditingController.dispose(); 91 | bodyTextEditingController.dispose(); 92 | super.dispose(); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /lib/pages/home/home_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:gap/gap.dart'; 3 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 4 | 5 | import '../../models/route_arg/todo_page/todo_page.dart'; 6 | import '../../models/todo/todo.dart'; 7 | import '../../services/scaffold_messenger.dart'; 8 | import '../../utils/route.dart'; 9 | import '../first/first_page.dart'; 10 | import '../second/second_page.dart'; 11 | import '../todo/todo_page.dart'; 12 | 13 | class HomePage extends HookConsumerWidget { 14 | const HomePage({super.key}); 15 | 16 | static const path = '/home/'; 17 | static const name = 'HomePage'; 18 | 19 | @override 20 | Widget build(BuildContext context, WidgetRef ref) { 21 | return Scaffold( 22 | appBar: AppBar( 23 | title: const Text('GitHub Search'), 24 | ), 25 | body: Center( 26 | child: Column( 27 | mainAxisAlignment: MainAxisAlignment.center, 28 | children: [ 29 | ElevatedButton( 30 | key: const ValueKey(FirstPage.path), 31 | onPressed: () => Navigator.pushNamed(context, FirstPage.path), 32 | child: const Text('Go to ${FirstPage.name}'), 33 | ), 34 | const Gap(16), 35 | ElevatedButton( 36 | key: const ValueKey(SecondPage.path), 37 | onPressed: () => Navigator.pushNamed(context, SecondPage.path), 38 | child: const Text('Go to ${SecondPage.name}'), 39 | ), 40 | const Gap(16), 41 | ElevatedButton( 42 | key: const ValueKey('${TodoPage.path}: instance'), 43 | onPressed: () => Navigator.pushNamed( 44 | context, 45 | TodoPage.path, 46 | arguments: RouteArguments( 47 | { 48 | 'arg': const TodoPageRouteArgument.instance( 49 | Todo( 50 | id: 1, 51 | title: '未完のタスク', 52 | isDone: false, 53 | ), 54 | ) 55 | }, 56 | ), 57 | ), 58 | child: const Text('Go to ${TodoPage.name} by instance'), 59 | ), 60 | const Gap(16), 61 | ElevatedButton( 62 | key: const ValueKey('${TodoPage.path}: id'), 63 | onPressed: () => Navigator.pushNamed( 64 | context, 65 | TodoPage.path, 66 | arguments: RouteArguments( 67 | { 68 | 'arg': const TodoPageRouteArgument.id(2), 69 | }, 70 | ), 71 | ), 72 | child: const Text('Go to ${TodoPage.name} by id'), 73 | ), 74 | const Gap(16), 75 | ElevatedButton( 76 | key: const ValueKey('SnackBar'), 77 | onPressed: () => 78 | ref.read(scaffoldMessengerServiceProvider).showSnackBar('A SnackBar is shown.'), 79 | child: const Text('Show SnackBar'), 80 | ), 81 | ], 82 | ), 83 | ), 84 | ); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /lib/widgets/issue/issue_item.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 3 | import 'package:gap/gap.dart'; 4 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 5 | import 'package:url_launcher/url_launcher.dart'; 6 | 7 | import '../../models/issue/issue.dart'; 8 | import '../../services/scaffold_messenger.dart'; 9 | import '../../utils/extensions/int.dart'; 10 | 11 | /// イシューのひとつひとつのウィジェット 12 | class IssueItemWidget extends HookConsumerWidget { 13 | const IssueItemWidget({ 14 | super.key, 15 | required this.issue, 16 | }); 17 | 18 | final Issue issue; 19 | 20 | @override 21 | Widget build(BuildContext context, WidgetRef ref) { 22 | return InkWell( 23 | onTap: () async { 24 | final urlString = issue.htmlUrl; 25 | final uri = Uri.parse(urlString); 26 | if (await canLaunchUrl(uri)) { 27 | await launchUrl(uri); 28 | } else { 29 | ref.read(scaffoldMessengerServiceProvider).showSnackBar('URL が開けませんでした:$urlString'); 30 | } 31 | }, 32 | child: Padding( 33 | padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 34 | child: Row( 35 | mainAxisAlignment: MainAxisAlignment.start, 36 | crossAxisAlignment: CrossAxisAlignment.start, 37 | children: [ 38 | const FaIcon(FontAwesomeIcons.github), 39 | const Gap(8), 40 | Expanded( 41 | child: Column( 42 | crossAxisAlignment: CrossAxisAlignment.start, 43 | children: [ 44 | Text( 45 | issue.title, 46 | maxLines: 1, 47 | overflow: TextOverflow.ellipsis, 48 | style: const TextStyle( 49 | fontSize: 16, 50 | fontWeight: FontWeight.w500, 51 | ), 52 | ), 53 | Text( 54 | issue.body, 55 | maxLines: 2, 56 | overflow: TextOverflow.ellipsis, 57 | style: const TextStyle( 58 | fontSize: 10, 59 | color: Colors.black54, 60 | ), 61 | ), 62 | Text( 63 | '作成日:${issue.createdAt.toString().substring(0, 10)}', 64 | style: const TextStyle( 65 | fontSize: 10, 66 | color: Colors.black54, 67 | ), 68 | ), 69 | ], 70 | ), 71 | ), 72 | const Gap(8), 73 | Column( 74 | crossAxisAlignment: CrossAxisAlignment.end, 75 | children: [ 76 | Text( 77 | issue.state, 78 | style: const TextStyle( 79 | fontSize: 12, 80 | color: Colors.black54, 81 | ), 82 | ), 83 | const Gap(4), 84 | Text( 85 | '#${issue.number.withComma}', 86 | style: const TextStyle( 87 | fontSize: 12, 88 | color: Colors.black54, 89 | ), 90 | ), 91 | ], 92 | ), 93 | ], 94 | ), 95 | ), 96 | ); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /lib/pages/repo/repo_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:gap/gap.dart'; 3 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 4 | 5 | import '../../constants/string.dart'; 6 | import '../../providers/repo/search_repo.dart'; 7 | import '../../widgets/common_text.dart'; 8 | import '../../widgets/fetch_summary.dart'; 9 | import '../../widgets/loading.dart'; 10 | import '../../widgets/pager.dart'; 11 | import '../../widgets/repo/repo_item.dart'; 12 | import '../../widgets/repo/text_field.dart'; 13 | 14 | class RepoPage extends HookConsumerWidget { 15 | const RepoPage({super.key}); 16 | 17 | static const path = '/search-repo/'; 18 | static const name = 'RepoPage'; 19 | static const gestureDetectorKey = ValueKey('RepoPage GestureDetector'); 20 | 21 | @override 22 | Widget build(BuildContext context, WidgetRef ref) { 23 | return GestureDetector( 24 | key: gestureDetectorKey, 25 | behavior: HitTestBehavior.opaque, 26 | onTap: () => FocusScope.of(context).unfocus(), 27 | child: Scaffold( 28 | appBar: AppBar( 29 | title: const Text('Repos'), 30 | ), 31 | body: Column( 32 | crossAxisAlignment: CrossAxisAlignment.start, 33 | children: const [ 34 | Gap(16), 35 | Padding( 36 | padding: EdgeInsets.symmetric(horizontal: 16), 37 | child: RepoPageTextField(), 38 | ), 39 | SearchRepoContentWidget(), 40 | ], 41 | ), 42 | ), 43 | ); 44 | } 45 | } 46 | 47 | /// SearchRepo の TextField 以下のウィジェット 48 | class SearchRepoContentWidget extends HookConsumerWidget { 49 | const SearchRepoContentWidget({super.key}); 50 | 51 | @override 52 | Widget build(BuildContext context, WidgetRef ref) { 53 | final state = ref.watch(searchRepoStateNotifierProvider); 54 | final notifier = ref.watch(searchRepoStateNotifierProvider.notifier); 55 | return Expanded( 56 | child: state.loading 57 | ? const PrimarySpinkitCircle() 58 | : state.repos.isEmpty 59 | ? const CommonTextWidget(emptyQMessage) 60 | : ListView.builder( 61 | // +2 は上部の Summary と下部の Pager 62 | controller: ref.watch(searchRepoStateNotifierProvider.notifier).scrollController, 63 | itemCount: state.repos.length + 2, 64 | itemBuilder: (context, index) { 65 | if (index == 0) { 66 | return FetchSummaryWidget( 67 | totalCount: state.totalCount, 68 | currentPage: state.currentPage, 69 | maxPage: state.maxPage, 70 | ); 71 | } 72 | if (index == state.repos.length + 1) { 73 | return Padding( 74 | padding: const EdgeInsets.only(bottom: 16), 75 | child: PagerWidget( 76 | canShowPreviousPage: state.canShowPreviousPage, 77 | canShowNextPage: state.canShowNextPage, 78 | showPreviousPage: notifier.showPreviousPage, 79 | showNextPage: notifier.showNextPage, 80 | ), 81 | ); 82 | } else { 83 | return RepoItemWidget(repo: state.repos[index - 1]); 84 | } 85 | }, 86 | ), 87 | ); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 37 | 38 | 39 | 40 | 41 | 42 | 52 | 54 | 60 | 61 | 62 | 63 | 69 | 71 | 77 | 78 | 79 | 80 | 82 | 83 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /lib/pages/issue/issue_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 3 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 4 | 5 | import '../../providers/issue/fetch_issue.dart'; 6 | import '../../services/scaffold_messenger.dart'; 7 | import '../../widgets/issue/create_issue_dialog.dart'; 8 | import '../../widgets/issue/issue_item.dart'; 9 | import '../../widgets/loading.dart'; 10 | import '../../widgets/pager.dart'; 11 | 12 | class IssuePage extends StatefulHookConsumerWidget { 13 | const IssuePage({super.key}); 14 | 15 | static const path = '/issue/'; 16 | static const name = 'IssuePage'; 17 | 18 | @override 19 | ConsumerState createState() => _IssuePageState(); 20 | } 21 | 22 | class _IssuePageState extends ConsumerState { 23 | @override 24 | Widget build(BuildContext context) { 25 | final state = ref.watch(fetchIssueStateNotifierProvider); 26 | final notifier = ref.watch(fetchIssueStateNotifierProvider.notifier); 27 | return Scaffold( 28 | appBar: AppBar(title: const Text('Issues')), 29 | body: state.loading 30 | ? const PrimarySpinkitCircle() 31 | : state.issues.isEmpty 32 | ? Center( 33 | child: Column( 34 | mainAxisAlignment: MainAxisAlignment.center, 35 | children: [ 36 | const Text('Issue が取得できませんでした。'), 37 | TextButton( 38 | onPressed: notifier.reload, 39 | child: const Text('リロードする'), 40 | ), 41 | ], 42 | ), 43 | ) 44 | : RefreshIndicator( 45 | onRefresh: notifier.reload, 46 | child: ListView.builder( 47 | // +1 は下部の Pager 48 | controller: 49 | ref.watch(fetchIssueStateNotifierProvider.notifier).scrollController, 50 | itemCount: state.issues.length + 1, 51 | itemBuilder: (context, index) { 52 | if (index == state.issues.length) { 53 | return Padding( 54 | padding: const EdgeInsets.only(bottom: 16), 55 | child: PagerWidget( 56 | canShowPreviousPage: state.canShowPreviousPage, 57 | canShowNextPage: state.canShowNextPage, 58 | showPreviousPage: notifier.showPreviousPage, 59 | showNextPage: notifier.showNextPage, 60 | ), 61 | ); 62 | } else { 63 | return IssueItemWidget(issue: state.issues[index]); 64 | } 65 | }, 66 | ), 67 | ), 68 | floatingActionButton: FloatingActionButton( 69 | onPressed: () async { 70 | final result = await showDialog( 71 | context: context, 72 | builder: (context) => const CreateIssueDialogDialog(), 73 | ); 74 | if (result ?? false) { 75 | await ref.read(fetchIssueStateNotifierProvider.notifier).reload(); 76 | ref.read(scaffoldMessengerServiceProvider).showSnackBar( 77 | 'Issue を作成しました。新しく作成した Issue が表示されない場合は、' 78 | '数秒程度待って、画面を下にスワイプしてリロードしてください。', 79 | ); 80 | } 81 | }, 82 | child: const FaIcon(FontAwesomeIcons.penToSquare), 83 | ), 84 | ); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /lib/widgets/repo/repo_item.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 3 | import 'package:gap/gap.dart'; 4 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 5 | import 'package:url_launcher/url_launcher.dart'; 6 | 7 | import '../../models/repo/repo.dart'; 8 | import '../../services/scaffold_messenger.dart'; 9 | import '../../utils/extensions/int.dart'; 10 | 11 | /// リポジトリのひとつひとつのウィジェット 12 | class RepoItemWidget extends HookConsumerWidget { 13 | const RepoItemWidget({ 14 | super.key, 15 | required this.repo, 16 | }); 17 | 18 | final Repo repo; 19 | 20 | @override 21 | Widget build(BuildContext context, WidgetRef ref) { 22 | return InkWell( 23 | onTap: () async { 24 | final urlString = repo.htmlUrl; 25 | final uri = Uri.parse(urlString); 26 | if (await canLaunchUrl(uri)) { 27 | await launchUrl(uri); 28 | } else { 29 | ref.read(scaffoldMessengerServiceProvider).showSnackBar('URL が開けませんでした:$urlString'); 30 | } 31 | }, 32 | child: Padding( 33 | padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 34 | child: Row( 35 | mainAxisAlignment: MainAxisAlignment.start, 36 | crossAxisAlignment: CrossAxisAlignment.start, 37 | children: [ 38 | const FaIcon(FontAwesomeIcons.github), 39 | const Gap(8), 40 | Expanded( 41 | child: Column( 42 | crossAxisAlignment: CrossAxisAlignment.start, 43 | children: [ 44 | Text( 45 | repo.name, 46 | maxLines: 1, 47 | overflow: TextOverflow.ellipsis, 48 | style: const TextStyle( 49 | fontSize: 16, 50 | fontWeight: FontWeight.w500, 51 | ), 52 | ), 53 | Text( 54 | repo.description, 55 | maxLines: 2, 56 | overflow: TextOverflow.ellipsis, 57 | style: const TextStyle( 58 | fontSize: 10, 59 | color: Colors.black54, 60 | ), 61 | ), 62 | Text( 63 | '更新日:${repo.updatedAt.toString().substring(0, 10)}', 64 | style: const TextStyle( 65 | fontSize: 10, 66 | color: Colors.black54, 67 | ), 68 | ), 69 | ], 70 | ), 71 | ), 72 | const Gap(8), 73 | Column( 74 | crossAxisAlignment: CrossAxisAlignment.end, 75 | children: [ 76 | Row( 77 | crossAxisAlignment: CrossAxisAlignment.center, 78 | children: [ 79 | const Icon(Icons.star, size: 12), 80 | const Gap(4), 81 | Text( 82 | repo.starGazersCount.withComma, 83 | style: const TextStyle( 84 | fontSize: 12, 85 | color: Colors.black54, 86 | ), 87 | ), 88 | ], 89 | ), 90 | const Gap(4), 91 | Row( 92 | crossAxisAlignment: CrossAxisAlignment.center, 93 | children: [ 94 | const FaIcon(FontAwesomeIcons.codeFork, size: 12), 95 | const Gap(4), 96 | Text( 97 | repo.forksCount.withComma, 98 | style: const TextStyle( 99 | fontSize: 12, 100 | color: Colors.black54, 101 | ), 102 | ), 103 | ], 104 | ), 105 | ], 106 | ), 107 | ], 108 | ), 109 | ), 110 | ); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /lib/providers/issue/fetch_issue.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 3 | 4 | import '../../models/response_data/issues_response/issues_response.dart'; 5 | import '../../repositories/issue.dart'; 6 | import '../../utils/api.dart'; 7 | import '../../utils/exceptions/api_exceptions.dart'; 8 | import 'fetch_issue_state.dart'; 9 | 10 | /// Issue の読み書きの対象としている GitHub リポジトリのオーナー名を提供するプロバイダ 11 | final issueOwnerNameProvider = 12 | StateProvider.autoDispose((_) => const String.fromEnvironment('GITHUB_OWNER_NAME')); 13 | 14 | /// Issue の読み書きの対象としている GitHub リポジトリの名前を提供するプロバイダ 15 | final issueRepoNameProvider = 16 | StateProvider.autoDispose((_) => const String.fromEnvironment('TARGET_GITHUB_REPO')); 17 | 18 | /// dart_define で指定している対象リポジトリのイシュー一覧を取得・保持するための 19 | /// StateNotifier を提供するプロバイダ。 20 | final fetchIssueStateNotifierProvider = 21 | StateNotifierProvider.autoDispose( 22 | (ref) => FetchIssueStateNotifier(ref.read), 23 | ); 24 | 25 | class FetchIssueStateNotifier extends StateNotifier { 26 | FetchIssueStateNotifier(this._read) : super(const FetchIssueState()) { 27 | _fetchIssues(); 28 | } 29 | 30 | final Reader _read; 31 | 32 | /// ListView.builder に指定するスクロールコントローラ 33 | final scrollController = ScrollController(); 34 | 35 | /// GET /repos/{owner}/{repo}/issues API をコールして結果を state に保持する 36 | Future _fetchIssues() async { 37 | if (state.currentPage < 1) { 38 | state = state.copyWith(loading: false, error: FetchResponseError.pageNotValid); 39 | return; 40 | } 41 | if (state.perPage < 1) { 42 | state = state.copyWith(loading: false, error: FetchResponseError.perPageNotValid); 43 | return; 44 | } 45 | try { 46 | state = state.copyWith(loading: true); 47 | final response = await _read(issueRepositoryProvider).fetchIssues( 48 | ownerName: _read(issueOwnerNameProvider), 49 | repoName: _read(issueRepoNameProvider), 50 | page: state.currentPage, 51 | perPage: state.perPage, 52 | ); 53 | _updateStateByResponse(response); 54 | } on ApiException { 55 | state = state.copyWith(error: FetchResponseError.apiError); 56 | } on Exception { 57 | state = state.copyWith(error: FetchResponseError.other); 58 | } finally { 59 | state = state.copyWith(loading: false); 60 | } 61 | } 62 | 63 | /// Issue の作成後などにリロードする 64 | Future reload() async { 65 | _animateToTop(); 66 | _resetPagerStatus(); 67 | await _fetchIssues(); 68 | } 69 | 70 | /// 前のページへ 71 | void showPreviousPage() { 72 | if (state.currentPage < 2) { 73 | return; 74 | } 75 | state = state.copyWith(currentPage: state.currentPage - 1); 76 | _resetPagerStatus(); 77 | _animateToTop(); 78 | _fetchIssues(); 79 | } 80 | 81 | /// 次のページへ 82 | void showNextPage() { 83 | if (state.issues.isEmpty) { 84 | return; 85 | } 86 | state = state.copyWith(currentPage: state.currentPage + 1); 87 | _resetPagerStatus(); 88 | _animateToTop(); 89 | _fetchIssues(); 90 | } 91 | 92 | /// GET /repos/{owner}/{repo}/issues API の結果に応じて更新すべき状態を更新する 93 | void _updateStateByResponse(IssuesResponse response) { 94 | state = state.copyWith( 95 | issues: response.issues, 96 | canShowNextPage: response.issues.length >= state.perPage, 97 | ); 98 | _resetPagerStatus(); 99 | } 100 | 101 | /// ページャに関わる状態を更新する 102 | void _resetPagerStatus() { 103 | state = state.copyWith( 104 | canShowPreviousPage: state.currentPage > 1, 105 | canShowNextPage: state.issues.length >= state.perPage, 106 | ); 107 | } 108 | 109 | /// ページ切替時に ListView の上までスクロールする 110 | void _animateToTop() { 111 | if (!scrollController.hasClients) { 112 | return; 113 | } 114 | scrollController.animateTo( 115 | 0, 116 | duration: const Duration(microseconds: 200), 117 | curve: Curves.linear, 118 | ); 119 | } 120 | 121 | @override 122 | void dispose() { 123 | scrollController.dispose(); 124 | super.dispose(); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /lib/providers/repo/search_repo.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 3 | 4 | import '../../models/repo/repo.dart'; 5 | import '../../models/response_data/search_repo_response/search_repo_response.dart'; 6 | import '../../repositories/search_repo.dart'; 7 | import '../../utils/api.dart'; 8 | import '../../utils/exceptions/api_exceptions.dart'; 9 | import 'search_repo_state.dart'; 10 | 11 | /// GitHub リポジトリの検索条件や検索結果を操作・保持する 12 | /// StateNotifier を提供するプロバイダ。 13 | /// 検索ワードやページャなどのそれぞれの要素が互いに割と複雑に影響しあっているので、 14 | /// バラバラの StateProvider や FutureProvider で記述していたのから、 15 | /// StateNotifier で記述することにした。 16 | final searchRepoStateNotifierProvider = 17 | StateNotifierProvider.autoDispose( 18 | (ref) => SearchRepoStateNotifier(ref.read), 19 | ); 20 | 21 | class SearchRepoStateNotifier extends StateNotifier { 22 | SearchRepoStateNotifier(this._read) : super(const SearchRepoState()) { 23 | _searchRepositories(); 24 | } 25 | 26 | final Reader _read; 27 | 28 | /// ListView.builder に指定するスクロールコントローラ 29 | final scrollController = ScrollController(); 30 | 31 | /// GET /search/repositories API をコールして検索結果をstate に保持する 32 | Future _searchRepositories() async { 33 | if (state.q.isEmpty) { 34 | state = state.copyWith(loading: false, error: FetchResponseError.emptyQ); 35 | return; 36 | } 37 | if (state.currentPage < 1) { 38 | state = state.copyWith(loading: false, error: FetchResponseError.pageNotValid); 39 | return; 40 | } 41 | if (state.perPage < 1) { 42 | state = state.copyWith(loading: false, error: FetchResponseError.perPageNotValid); 43 | return; 44 | } 45 | try { 46 | state = state.copyWith(loading: true); 47 | final response = await _read(searchRepoRepositoryProvider).fetchRepositories( 48 | q: state.q, 49 | page: state.currentPage, 50 | perPage: state.perPage, 51 | ); 52 | _updateStateByResponse(response); 53 | } on ApiException { 54 | state = state.copyWith(error: FetchResponseError.apiError); 55 | } on Exception { 56 | state = state.copyWith(error: FetchResponseError.other); 57 | } finally { 58 | state = state.copyWith(loading: false); 59 | } 60 | } 61 | 62 | /// 検索ワードを変更して再度 Search Repository API をコールする 63 | void updateSearchWord(String q) { 64 | state = state.copyWith(q: q, currentPage: 1, repos: []); 65 | _resetPagerStatus(); 66 | _animateToTop(); 67 | _searchRepositories(); 68 | } 69 | 70 | /// 前のページへ 71 | void showPreviousPage() { 72 | if (state.currentPage < 2) { 73 | return; 74 | } 75 | state = state.copyWith(currentPage: state.currentPage - 1); 76 | _resetPagerStatus(); 77 | _animateToTop(); 78 | _searchRepositories(); 79 | } 80 | 81 | /// 次のページへ 82 | void showNextPage() { 83 | if (state.currentPage >= state.maxPage) { 84 | return; 85 | } 86 | state = state.copyWith(currentPage: state.currentPage + 1); 87 | _resetPagerStatus(); 88 | _animateToTop(); 89 | _searchRepositories(); 90 | } 91 | 92 | /// GET /search/repository の結果に応じて更新すべき状態を更新する 93 | void _updateStateByResponse(SearchRepoResponse response) { 94 | state = state.copyWith( 95 | totalCount: response.totalCount, 96 | maxPage: (response.totalCount / state.perPage).ceil(), 97 | repos: response.repos, 98 | ); 99 | _resetPagerStatus(); 100 | } 101 | 102 | /// ページャに関わる状態を更新する 103 | void _resetPagerStatus() { 104 | state = state.copyWith( 105 | canShowPreviousPage: state.currentPage > 1, 106 | canShowNextPage: state.currentPage < state.maxPage, 107 | ); 108 | } 109 | 110 | /// ページ切替時に ListView の上までスクロールする 111 | void _animateToTop() { 112 | if (!scrollController.hasClients) { 113 | return; 114 | } 115 | scrollController.animateTo( 116 | 0, 117 | duration: const Duration(microseconds: 200), 118 | curve: Curves.linear, 119 | ); 120 | } 121 | 122 | @override 123 | void dispose() { 124 | scrollController.dispose(); 125 | super.dispose(); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /lib/widgets/issue/create_issue_dialog.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:gap/gap.dart'; 3 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 4 | 5 | import '../../providers/issue/create_issue_dialog.dart'; 6 | import '../../utils/exceptions/api_exceptions.dart'; 7 | import '../../utils/exceptions/base.dart'; 8 | 9 | /// イシューを作成するためのフォームを入力させるダイアログ 10 | class CreateIssueDialogDialog extends StatefulHookConsumerWidget { 11 | const CreateIssueDialogDialog({super.key}); 12 | 13 | @override 14 | ConsumerState createState() => _CreateIssueDialogDialogState(); 15 | } 16 | 17 | class _CreateIssueDialogDialogState extends ConsumerState { 18 | @override 19 | Widget build(BuildContext context) { 20 | final sending = 21 | ref.watch(createIssueDialogStateNotifierProvider.select((value) => value.sending)); 22 | final notifier = ref.watch(createIssueDialogStateNotifierProvider.notifier); 23 | return GestureDetector( 24 | onTap: FocusScope.of(context).unfocus, 25 | child: ScaffoldMessenger( 26 | key: notifier.scaffoldMessengerKey, 27 | child: Scaffold( 28 | backgroundColor: Colors.transparent, 29 | body: AlertDialog( 30 | title: const Text('Issue の作成'), 31 | content: SizedBox( 32 | width: 280, 33 | child: SingleChildScrollView( 34 | child: Column( 35 | crossAxisAlignment: CrossAxisAlignment.start, 36 | children: [ 37 | const Text( 38 | 'タイトル', 39 | style: TextStyle( 40 | fontSize: 12, 41 | color: Colors.black87, 42 | ), 43 | ), 44 | const Gap(8), 45 | TextField( 46 | controller: notifier.titleTextEditingController, 47 | maxLines: 1, 48 | decoration: const InputDecoration( 49 | border: OutlineInputBorder(), 50 | ), 51 | ), 52 | const Gap(16), 53 | const Text( 54 | '内容', 55 | style: TextStyle( 56 | fontSize: 12, 57 | color: Colors.black87, 58 | ), 59 | ), 60 | const Gap(8), 61 | TextField( 62 | controller: notifier.bodyTextEditingController, 63 | minLines: 3, 64 | maxLines: 5, 65 | decoration: const InputDecoration( 66 | border: OutlineInputBorder(), 67 | ), 68 | ), 69 | ], 70 | ), 71 | ), 72 | ), 73 | actions: [ 74 | TextButton( 75 | onPressed: Navigator.of(context).pop, 76 | child: const Text('閉じる'), 77 | ), 78 | ElevatedButton( 79 | onPressed: sending 80 | ? null 81 | : () async { 82 | final notifier = ref.read(createIssueDialogStateNotifierProvider.notifier); 83 | try { 84 | await notifier.createIssueDialog(); 85 | if (!mounted) { 86 | return; 87 | } 88 | Navigator.pop(context, true); 89 | } on ApiException catch (e) { 90 | notifier.showSnackBarOnDialog(e.toString()); 91 | } on AppException catch (e) { 92 | notifier.showSnackBarOnDialog(e.toString()); 93 | } on Exception catch (e) { 94 | notifier.showSnackBarOnDialog(e.toString()); 95 | } 96 | }, 97 | child: sending 98 | ? const SizedBox( 99 | width: 14, 100 | height: 14, 101 | child: CircularProgressIndicator(strokeWidth: 2), 102 | ) 103 | : const Text('作成する'), 104 | ), 105 | ], 106 | ), 107 | ), 108 | ), 109 | ); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /lib/providers/application/application_state.freezed.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | // GENERATED CODE - DO NOT MODIFY BY HAND 3 | // ignore_for_file: type=lint 4 | // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target 5 | 6 | part of 'application_state.dart'; 7 | 8 | // ************************************************************************** 9 | // FreezedGenerator 10 | // ************************************************************************** 11 | 12 | T _$identity(T value) => value; 13 | 14 | final _privateConstructorUsedError = UnsupportedError( 15 | 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); 16 | 17 | /// @nodoc 18 | mixin _$ApplicationState { 19 | bool get loading => throw _privateConstructorUsedError; 20 | 21 | @JsonKey(ignore: true) 22 | $ApplicationStateCopyWith get copyWith => 23 | throw _privateConstructorUsedError; 24 | } 25 | 26 | /// @nodoc 27 | abstract class $ApplicationStateCopyWith<$Res> { 28 | factory $ApplicationStateCopyWith( 29 | ApplicationState value, $Res Function(ApplicationState) then) = 30 | _$ApplicationStateCopyWithImpl<$Res>; 31 | $Res call({bool loading}); 32 | } 33 | 34 | /// @nodoc 35 | class _$ApplicationStateCopyWithImpl<$Res> 36 | implements $ApplicationStateCopyWith<$Res> { 37 | _$ApplicationStateCopyWithImpl(this._value, this._then); 38 | 39 | final ApplicationState _value; 40 | // ignore: unused_field 41 | final $Res Function(ApplicationState) _then; 42 | 43 | @override 44 | $Res call({ 45 | Object? loading = freezed, 46 | }) { 47 | return _then(_value.copyWith( 48 | loading: loading == freezed 49 | ? _value.loading 50 | : loading // ignore: cast_nullable_to_non_nullable 51 | as bool, 52 | )); 53 | } 54 | } 55 | 56 | /// @nodoc 57 | abstract class _$$_ApplicationStateCopyWith<$Res> 58 | implements $ApplicationStateCopyWith<$Res> { 59 | factory _$$_ApplicationStateCopyWith( 60 | _$_ApplicationState value, $Res Function(_$_ApplicationState) then) = 61 | __$$_ApplicationStateCopyWithImpl<$Res>; 62 | @override 63 | $Res call({bool loading}); 64 | } 65 | 66 | /// @nodoc 67 | class __$$_ApplicationStateCopyWithImpl<$Res> 68 | extends _$ApplicationStateCopyWithImpl<$Res> 69 | implements _$$_ApplicationStateCopyWith<$Res> { 70 | __$$_ApplicationStateCopyWithImpl( 71 | _$_ApplicationState _value, $Res Function(_$_ApplicationState) _then) 72 | : super(_value, (v) => _then(v as _$_ApplicationState)); 73 | 74 | @override 75 | _$_ApplicationState get _value => super._value as _$_ApplicationState; 76 | 77 | @override 78 | $Res call({ 79 | Object? loading = freezed, 80 | }) { 81 | return _then(_$_ApplicationState( 82 | loading: loading == freezed 83 | ? _value.loading 84 | : loading // ignore: cast_nullable_to_non_nullable 85 | as bool, 86 | )); 87 | } 88 | } 89 | 90 | /// @nodoc 91 | 92 | class _$_ApplicationState implements _ApplicationState { 93 | const _$_ApplicationState({this.loading = false}); 94 | 95 | @override 96 | @JsonKey() 97 | final bool loading; 98 | 99 | @override 100 | String toString() { 101 | return 'ApplicationState(loading: $loading)'; 102 | } 103 | 104 | @override 105 | bool operator ==(dynamic other) { 106 | return identical(this, other) || 107 | (other.runtimeType == runtimeType && 108 | other is _$_ApplicationState && 109 | const DeepCollectionEquality().equals(other.loading, loading)); 110 | } 111 | 112 | @override 113 | int get hashCode => 114 | Object.hash(runtimeType, const DeepCollectionEquality().hash(loading)); 115 | 116 | @JsonKey(ignore: true) 117 | @override 118 | _$$_ApplicationStateCopyWith<_$_ApplicationState> get copyWith => 119 | __$$_ApplicationStateCopyWithImpl<_$_ApplicationState>(this, _$identity); 120 | } 121 | 122 | abstract class _ApplicationState implements ApplicationState { 123 | const factory _ApplicationState({final bool loading}) = _$_ApplicationState; 124 | 125 | @override 126 | bool get loading => throw _privateConstructorUsedError; 127 | @override 128 | @JsonKey(ignore: true) 129 | _$$_ApplicationStateCopyWith<_$_ApplicationState> get copyWith => 130 | throw _privateConstructorUsedError; 131 | } 132 | -------------------------------------------------------------------------------- /lib/providers/issue/create_issue_dialog_state.freezed.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | // GENERATED CODE - DO NOT MODIFY BY HAND 3 | // ignore_for_file: type=lint 4 | // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target 5 | 6 | part of 'create_issue_dialog_state.dart'; 7 | 8 | // ************************************************************************** 9 | // FreezedGenerator 10 | // ************************************************************************** 11 | 12 | T _$identity(T value) => value; 13 | 14 | final _privateConstructorUsedError = UnsupportedError( 15 | 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); 16 | 17 | /// @nodoc 18 | mixin _$CreateIssueDialogState { 19 | bool get sending => throw _privateConstructorUsedError; 20 | 21 | @JsonKey(ignore: true) 22 | $CreateIssueDialogStateCopyWith get copyWith => 23 | throw _privateConstructorUsedError; 24 | } 25 | 26 | /// @nodoc 27 | abstract class $CreateIssueDialogStateCopyWith<$Res> { 28 | factory $CreateIssueDialogStateCopyWith(CreateIssueDialogState value, 29 | $Res Function(CreateIssueDialogState) then) = 30 | _$CreateIssueDialogStateCopyWithImpl<$Res>; 31 | $Res call({bool sending}); 32 | } 33 | 34 | /// @nodoc 35 | class _$CreateIssueDialogStateCopyWithImpl<$Res> 36 | implements $CreateIssueDialogStateCopyWith<$Res> { 37 | _$CreateIssueDialogStateCopyWithImpl(this._value, this._then); 38 | 39 | final CreateIssueDialogState _value; 40 | // ignore: unused_field 41 | final $Res Function(CreateIssueDialogState) _then; 42 | 43 | @override 44 | $Res call({ 45 | Object? sending = freezed, 46 | }) { 47 | return _then(_value.copyWith( 48 | sending: sending == freezed 49 | ? _value.sending 50 | : sending // ignore: cast_nullable_to_non_nullable 51 | as bool, 52 | )); 53 | } 54 | } 55 | 56 | /// @nodoc 57 | abstract class _$$_CreateIssueDialogStateCopyWith<$Res> 58 | implements $CreateIssueDialogStateCopyWith<$Res> { 59 | factory _$$_CreateIssueDialogStateCopyWith(_$_CreateIssueDialogState value, 60 | $Res Function(_$_CreateIssueDialogState) then) = 61 | __$$_CreateIssueDialogStateCopyWithImpl<$Res>; 62 | @override 63 | $Res call({bool sending}); 64 | } 65 | 66 | /// @nodoc 67 | class __$$_CreateIssueDialogStateCopyWithImpl<$Res> 68 | extends _$CreateIssueDialogStateCopyWithImpl<$Res> 69 | implements _$$_CreateIssueDialogStateCopyWith<$Res> { 70 | __$$_CreateIssueDialogStateCopyWithImpl(_$_CreateIssueDialogState _value, 71 | $Res Function(_$_CreateIssueDialogState) _then) 72 | : super(_value, (v) => _then(v as _$_CreateIssueDialogState)); 73 | 74 | @override 75 | _$_CreateIssueDialogState get _value => 76 | super._value as _$_CreateIssueDialogState; 77 | 78 | @override 79 | $Res call({ 80 | Object? sending = freezed, 81 | }) { 82 | return _then(_$_CreateIssueDialogState( 83 | sending: sending == freezed 84 | ? _value.sending 85 | : sending // ignore: cast_nullable_to_non_nullable 86 | as bool, 87 | )); 88 | } 89 | } 90 | 91 | /// @nodoc 92 | 93 | class _$_CreateIssueDialogState implements _CreateIssueDialogState { 94 | const _$_CreateIssueDialogState({this.sending = false}); 95 | 96 | @override 97 | @JsonKey() 98 | final bool sending; 99 | 100 | @override 101 | String toString() { 102 | return 'CreateIssueDialogState(sending: $sending)'; 103 | } 104 | 105 | @override 106 | bool operator ==(dynamic other) { 107 | return identical(this, other) || 108 | (other.runtimeType == runtimeType && 109 | other is _$_CreateIssueDialogState && 110 | const DeepCollectionEquality().equals(other.sending, sending)); 111 | } 112 | 113 | @override 114 | int get hashCode => 115 | Object.hash(runtimeType, const DeepCollectionEquality().hash(sending)); 116 | 117 | @JsonKey(ignore: true) 118 | @override 119 | _$$_CreateIssueDialogStateCopyWith<_$_CreateIssueDialogState> get copyWith => 120 | __$$_CreateIssueDialogStateCopyWithImpl<_$_CreateIssueDialogState>( 121 | this, _$identity); 122 | } 123 | 124 | abstract class _CreateIssueDialogState implements CreateIssueDialogState { 125 | const factory _CreateIssueDialogState({final bool sending}) = 126 | _$_CreateIssueDialogState; 127 | 128 | @override 129 | bool get sending => throw _privateConstructorUsedError; 130 | @override 131 | @JsonKey(ignore: true) 132 | _$$_CreateIssueDialogStateCopyWith<_$_CreateIssueDialogState> get copyWith => 133 | throw _privateConstructorUsedError; 134 | } 135 | -------------------------------------------------------------------------------- /lib/models/todo/todo.freezed.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | // GENERATED CODE - DO NOT MODIFY BY HAND 3 | // ignore_for_file: type=lint 4 | // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target 5 | 6 | part of 'todo.dart'; 7 | 8 | // ************************************************************************** 9 | // FreezedGenerator 10 | // ************************************************************************** 11 | 12 | T _$identity(T value) => value; 13 | 14 | final _privateConstructorUsedError = UnsupportedError( 15 | 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); 16 | 17 | /// @nodoc 18 | mixin _$Todo { 19 | int get id => throw _privateConstructorUsedError; 20 | String get title => throw _privateConstructorUsedError; 21 | bool get isDone => throw _privateConstructorUsedError; 22 | 23 | @JsonKey(ignore: true) 24 | $TodoCopyWith get copyWith => throw _privateConstructorUsedError; 25 | } 26 | 27 | /// @nodoc 28 | abstract class $TodoCopyWith<$Res> { 29 | factory $TodoCopyWith(Todo value, $Res Function(Todo) then) = 30 | _$TodoCopyWithImpl<$Res>; 31 | $Res call({int id, String title, bool isDone}); 32 | } 33 | 34 | /// @nodoc 35 | class _$TodoCopyWithImpl<$Res> implements $TodoCopyWith<$Res> { 36 | _$TodoCopyWithImpl(this._value, this._then); 37 | 38 | final Todo _value; 39 | // ignore: unused_field 40 | final $Res Function(Todo) _then; 41 | 42 | @override 43 | $Res call({ 44 | Object? id = freezed, 45 | Object? title = freezed, 46 | Object? isDone = freezed, 47 | }) { 48 | return _then(_value.copyWith( 49 | id: id == freezed 50 | ? _value.id 51 | : id // ignore: cast_nullable_to_non_nullable 52 | as int, 53 | title: title == freezed 54 | ? _value.title 55 | : title // ignore: cast_nullable_to_non_nullable 56 | as String, 57 | isDone: isDone == freezed 58 | ? _value.isDone 59 | : isDone // ignore: cast_nullable_to_non_nullable 60 | as bool, 61 | )); 62 | } 63 | } 64 | 65 | /// @nodoc 66 | abstract class _$$_TodoCopyWith<$Res> implements $TodoCopyWith<$Res> { 67 | factory _$$_TodoCopyWith(_$_Todo value, $Res Function(_$_Todo) then) = 68 | __$$_TodoCopyWithImpl<$Res>; 69 | @override 70 | $Res call({int id, String title, bool isDone}); 71 | } 72 | 73 | /// @nodoc 74 | class __$$_TodoCopyWithImpl<$Res> extends _$TodoCopyWithImpl<$Res> 75 | implements _$$_TodoCopyWith<$Res> { 76 | __$$_TodoCopyWithImpl(_$_Todo _value, $Res Function(_$_Todo) _then) 77 | : super(_value, (v) => _then(v as _$_Todo)); 78 | 79 | @override 80 | _$_Todo get _value => super._value as _$_Todo; 81 | 82 | @override 83 | $Res call({ 84 | Object? id = freezed, 85 | Object? title = freezed, 86 | Object? isDone = freezed, 87 | }) { 88 | return _then(_$_Todo( 89 | id: id == freezed 90 | ? _value.id 91 | : id // ignore: cast_nullable_to_non_nullable 92 | as int, 93 | title: title == freezed 94 | ? _value.title 95 | : title // ignore: cast_nullable_to_non_nullable 96 | as String, 97 | isDone: isDone == freezed 98 | ? _value.isDone 99 | : isDone // ignore: cast_nullable_to_non_nullable 100 | as bool, 101 | )); 102 | } 103 | } 104 | 105 | /// @nodoc 106 | 107 | class _$_Todo implements _Todo { 108 | const _$_Todo({required this.id, required this.title, this.isDone = false}); 109 | 110 | @override 111 | final int id; 112 | @override 113 | final String title; 114 | @override 115 | @JsonKey() 116 | final bool isDone; 117 | 118 | @override 119 | String toString() { 120 | return 'Todo(id: $id, title: $title, isDone: $isDone)'; 121 | } 122 | 123 | @override 124 | bool operator ==(dynamic other) { 125 | return identical(this, other) || 126 | (other.runtimeType == runtimeType && 127 | other is _$_Todo && 128 | const DeepCollectionEquality().equals(other.id, id) && 129 | const DeepCollectionEquality().equals(other.title, title) && 130 | const DeepCollectionEquality().equals(other.isDone, isDone)); 131 | } 132 | 133 | @override 134 | int get hashCode => Object.hash( 135 | runtimeType, 136 | const DeepCollectionEquality().hash(id), 137 | const DeepCollectionEquality().hash(title), 138 | const DeepCollectionEquality().hash(isDone)); 139 | 140 | @JsonKey(ignore: true) 141 | @override 142 | _$$_TodoCopyWith<_$_Todo> get copyWith => 143 | __$$_TodoCopyWithImpl<_$_Todo>(this, _$identity); 144 | } 145 | 146 | abstract class _Todo implements Todo { 147 | const factory _Todo( 148 | {required final int id, 149 | required final String title, 150 | final bool isDone}) = _$_Todo; 151 | 152 | @override 153 | int get id => throw _privateConstructorUsedError; 154 | @override 155 | String get title => throw _privateConstructorUsedError; 156 | @override 157 | bool get isDone => throw _privateConstructorUsedError; 158 | @override 159 | @JsonKey(ignore: true) 160 | _$$_TodoCopyWith<_$_Todo> get copyWith => throw _privateConstructorUsedError; 161 | } 162 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:flutter_lints/flutter.yaml 2 | 3 | analyzer: 4 | exclude: 5 | - lib/**/**.freezed.dart 6 | - lib/**/**.g.dart 7 | - test/import_all_test.dart 8 | errors: 9 | missing_required_param: warning 10 | missing_return: warning 11 | # Remi さんの推奨設定 12 | # https://github.com/dart-lang/sdk/issues/46844 13 | # freezed モデルの invalid_annotation_target の警告を消す目的 14 | invalid_annotation_target: ignore 15 | strong-mode: 16 | implicit-casts: false 17 | implicit-dynamic: false 18 | 19 | linter: 20 | rules: 21 | - always_declare_return_types 22 | - always_put_control_body_on_new_line 23 | - always_require_non_null_named_parameters 24 | - annotate_overrides 25 | - avoid_bool_literals_in_conditional_expressions 26 | - avoid_catches_without_on_clauses 27 | - avoid_catching_errors 28 | - avoid_classes_with_only_static_members 29 | - avoid_double_and_int_checks 30 | - avoid_empty_else 31 | - avoid_field_initializers_in_const_classes 32 | - avoid_function_literals_in_foreach_calls 33 | - avoid_implementing_value_types 34 | - avoid_init_to_null 35 | - avoid_js_rounded_ints 36 | - avoid_null_checks_in_equality_operators 37 | - avoid_positional_boolean_parameters 38 | - avoid_private_typedef_functions 39 | - avoid_relative_lib_imports 40 | - avoid_renaming_method_parameters 41 | - avoid_return_types_on_setters 42 | - avoid_returning_null 43 | - avoid_returning_null_for_void 44 | - avoid_returning_this 45 | - avoid_setters_without_getters 46 | - avoid_shadowing_type_parameters 47 | - avoid_single_cascade_in_expression_statements 48 | - avoid_slow_async_io 49 | - avoid_type_to_string 50 | - avoid_types_as_parameter_names 51 | - avoid_types_on_closure_parameters 52 | - avoid_unused_constructor_parameters 53 | - avoid_void_async 54 | - await_only_futures 55 | - camel_case_extensions 56 | - camel_case_types 57 | - cancel_subscriptions 58 | - cast_nullable_to_non_nullable 59 | - close_sinks 60 | - comment_references 61 | - constant_identifier_names 62 | - control_flow_in_finally 63 | - curly_braces_in_flow_control_structures 64 | - directives_ordering 65 | - empty_catches 66 | - empty_constructor_bodies 67 | - empty_statements 68 | - exhaustive_cases 69 | - file_names 70 | - hash_and_equals 71 | - implementation_imports 72 | - invariant_booleans 73 | - iterable_contains_unrelated_type 74 | - join_return_with_assignment 75 | - library_names 76 | - library_prefixes 77 | - list_remove_unrelated_type 78 | - literal_only_boolean_expressions 79 | - no_adjacent_strings_in_list 80 | - no_default_cases 81 | - no_duplicate_case_values 82 | - non_constant_identifier_names 83 | - null_check_on_nullable_type_parameter 84 | - null_closures 85 | - omit_local_variable_types 86 | - one_member_abstracts 87 | - only_throw_errors 88 | - overridden_fields 89 | - package_api_docs 90 | - package_names 91 | - package_prefixed_library_names 92 | - parameter_assignments 93 | - prefer_adjacent_string_concatenation 94 | - prefer_asserts_in_initializer_lists 95 | - prefer_collection_literals 96 | - prefer_conditional_assignment 97 | - prefer_const_constructors 98 | - prefer_const_constructors_in_immutables 99 | - prefer_const_declarations 100 | - prefer_const_literals_to_create_immutables 101 | - prefer_constructors_over_static_methods 102 | - prefer_contains 103 | - prefer_equal_for_default_values 104 | - prefer_final_fields 105 | - prefer_final_in_for_each 106 | - prefer_final_locals 107 | - prefer_for_elements_to_map_fromIterable 108 | - prefer_foreach 109 | - prefer_function_declarations_over_variables 110 | - prefer_generic_function_type_aliases 111 | - prefer_if_null_operators 112 | - prefer_initializing_formals 113 | - prefer_int_literals 114 | - prefer_interpolation_to_compose_strings 115 | - prefer_is_empty 116 | - prefer_is_not_empty 117 | - prefer_iterable_whereType 118 | - prefer_null_aware_operators 119 | - prefer_relative_imports 120 | - prefer_single_quotes 121 | - prefer_spread_collections 122 | - prefer_typing_uninitialized_variables 123 | - prefer_void_to_null 124 | - recursive_getters 125 | - require_trailing_commas 126 | - sized_box_for_whitespace 127 | - slash_for_doc_comments 128 | - sort_constructors_first 129 | - sort_pub_dependencies 130 | - sort_unnamed_constructors_first 131 | - test_types_in_equals 132 | - throw_in_finally 133 | - tighten_type_of_initializing_formals 134 | - type_annotate_public_apis 135 | - type_init_formals 136 | - unawaited_futures 137 | - unnecessary_await_in_return 138 | - unnecessary_brace_in_string_interps 139 | - unnecessary_const 140 | - unnecessary_getters_setters 141 | - unnecessary_lambdas 142 | - unnecessary_new 143 | - unnecessary_null_aware_assignments 144 | - unnecessary_null_checks 145 | - unnecessary_null_in_if_null_operators 146 | - unnecessary_nullable_for_final_variable_declarations 147 | - unnecessary_overrides 148 | - unnecessary_parenthesis 149 | - unnecessary_statements 150 | - unnecessary_this 151 | - unrelated_type_equality_checks 152 | - use_function_type_syntax_for_parameters 153 | - use_is_even_rather_than_modulo 154 | - use_late_for_private_fields_and_variables 155 | - use_rethrow_when_possible 156 | - use_setters_to_change_properties 157 | - use_string_buffers 158 | - use_super_parameters 159 | - use_to_and_as_if_applicable 160 | - valid_regexps 161 | - void_checks 162 | -------------------------------------------------------------------------------- /test/import_all_test.dart: -------------------------------------------------------------------------------- 1 | /// *** GENERATED FILE - ANY CHANGES WOULD BE OBSOLETE ON NEXT GENERATION *** /// 2 | 3 | // ignore_for_file: unused_import 4 | 5 | /// Helper to test coverage for all project files 6 | import 'package:flutter_github_search/route/router.dart'; 7 | import 'package:flutter_github_search/route/routes.dart'; 8 | import 'package:flutter_github_search/route/bottom_tabs.dart'; 9 | import 'package:flutter_github_search/route/go_router.dart'; 10 | import 'package:flutter_github_search/constants/map.dart'; 11 | import 'package:flutter_github_search/constants/snack_bar.dart'; 12 | import 'package:flutter_github_search/constants/number.dart'; 13 | import 'package:flutter_github_search/constants/localization.dart'; 14 | import 'package:flutter_github_search/providers/common/use_mock.dart'; 15 | import 'package:flutter_github_search/providers/common/github_access_token.dart'; 16 | import 'package:flutter_github_search/providers/common/dio.dart'; 17 | import 'package:flutter_github_search/providers/common/cookie.dart'; 18 | import 'package:flutter_github_search/providers/common/application_documents_directory.dart'; 19 | import 'package:flutter_github_search/providers/issue/create_issue_dialog_state.dart'; 20 | import 'package:flutter_github_search/providers/issue/fetch_issue.dart'; 21 | import 'package:flutter_github_search/providers/issue/fetch_issue_state.dart'; 22 | import 'package:flutter_github_search/providers/bottom_tab/bottom_tab.dart'; 23 | import 'package:flutter_github_search/providers/application/application.dart'; 24 | import 'package:flutter_github_search/providers/application/application_state.dart'; 25 | import 'package:flutter_github_search/providers/repo/search_repo.dart'; 26 | import 'package:flutter_github_search/providers/repo/search_repo_state.dart'; 27 | import 'package:flutter_github_search/utils/provider_scope.dart'; 28 | import 'package:flutter_github_search/utils/route.dart'; 29 | import 'package:flutter_github_search/utils/exceptions/api_exceptions.dart'; 30 | import 'package:flutter_github_search/utils/exceptions/base.dart'; 31 | import 'package:flutter_github_search/utils/exceptions/common_exceptions.dart'; 32 | import 'package:flutter_github_search/utils/extensions/iterable.dart'; 33 | import 'package:flutter_github_search/utils/extensions/exception.dart'; 34 | import 'package:flutter_github_search/utils/extensions/map.dart'; 35 | import 'package:flutter_github_search/utils/extensions/dio.dart'; 36 | import 'package:flutter_github_search/utils/extensions/build_context.dart'; 37 | import 'package:flutter_github_search/utils/extensions/int.dart'; 38 | import 'package:flutter_github_search/utils/types.dart'; 39 | import 'package:flutter_github_search/utils/connectivity.dart'; 40 | import 'package:flutter_github_search/utils/timer.dart'; 41 | import 'package:flutter_github_search/utils/dio_interceptors/response_interceptor.dart'; 42 | import 'package:flutter_github_search/utils/dio_interceptors/mock_interceptor.dart'; 43 | import 'package:flutter_github_search/utils/dio_interceptors/request_interceptor.dart'; 44 | import 'package:flutter_github_search/utils/dio_interceptors/header_interceptor.dart'; 45 | import 'package:flutter_github_search/utils/dio_interceptors/connectivity_interceptor.dart'; 46 | import 'package:flutter_github_search/utils/bool.dart'; 47 | import 'package:flutter_github_search/utils/api.dart'; 48 | import 'package:flutter_github_search/repositories/search_repo.dart'; 49 | import 'package:flutter_github_search/repositories/issue.dart'; 50 | import 'package:flutter_github_search/models/issue/issue.dart'; 51 | import 'package:flutter_github_search/models/repo/owner/owner.dart'; 52 | import 'package:flutter_github_search/models/repo/repo.dart'; 53 | import 'package:flutter_github_search/models/response_data/response_result/response_result.dart'; 54 | import 'package:flutter_github_search/models/response_data/base_response_data/base_response_data.dart'; 55 | import 'package:flutter_github_search/models/response_data/issues_response/issues_response.dart'; 56 | import 'package:flutter_github_search/models/response_data/search_repo_response/search_repo_response.dart'; 57 | import 'package:flutter_github_search/models/response_data/issue_response/issue_response.dart'; 58 | import 'package:flutter_github_search/models/json_converter.dart'; 59 | import 'package:flutter_github_search/main.dart'; 60 | import 'package:flutter_github_search/pages/home/home_page.dart'; 61 | import 'package:flutter_github_search/pages/first/first_page.dart'; 62 | import 'package:flutter_github_search/pages/second/second_page.dart'; 63 | import 'package:flutter_github_search/pages/not_found/not_found_page.dart'; 64 | import 'package:flutter_github_search/pages/issue/issue_page.dart'; 65 | import 'package:flutter_github_search/pages/main/main_page.dart'; 66 | import 'package:flutter_github_search/pages/repo/repo_page.dart'; 67 | import 'package:flutter_github_search/app.dart'; 68 | import 'package:flutter_github_search/services/shared_preferences.dart'; 69 | import 'package:flutter_github_search/services/abstract_api_client.dart'; 70 | import 'package:flutter_github_search/services/navigation.dart'; 71 | import 'package:flutter_github_search/services/api_client.dart'; 72 | import 'package:flutter_github_search/services/scaffold_messenger.dart'; 73 | import 'package:flutter_github_search/widgets/pager.dart'; 74 | import 'package:flutter_github_search/widgets/fetch_summary.dart'; 75 | import 'package:flutter_github_search/widgets/root.dart'; 76 | import 'package:flutter_github_search/widgets/issue/issue_item.dart'; 77 | import 'package:flutter_github_search/widgets/scaffold_messenger_navigator.dart'; 78 | import 'package:flutter_github_search/widgets/repo/text_field.dart'; 79 | import 'package:flutter_github_search/widgets/repo/repo_item.dart'; 80 | import 'package:flutter_github_search/widgets/main_stacked_pages_navigator.dart'; 81 | import 'package:flutter_github_search/widgets/common_text.dart'; 82 | 83 | void main() {} 84 | -------------------------------------------------------------------------------- /lib/models/repo/owner/owner.freezed.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | // GENERATED CODE - DO NOT MODIFY BY HAND 3 | // ignore_for_file: type=lint 4 | // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target 5 | 6 | part of 'owner.dart'; 7 | 8 | // ************************************************************************** 9 | // FreezedGenerator 10 | // ************************************************************************** 11 | 12 | T _$identity(T value) => value; 13 | 14 | final _privateConstructorUsedError = UnsupportedError( 15 | 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); 16 | 17 | Owner _$OwnerFromJson(Map json) { 18 | return _Owner.fromJson(json); 19 | } 20 | 21 | /// @nodoc 22 | mixin _$Owner { 23 | int get id => throw _privateConstructorUsedError; 24 | @JsonKey(name: 'avatar_url') 25 | String get avatarUrl => throw _privateConstructorUsedError; 26 | @JsonKey(name: 'html_url') 27 | String get htmlUrl => throw _privateConstructorUsedError; 28 | 29 | Map toJson() => throw _privateConstructorUsedError; 30 | @JsonKey(ignore: true) 31 | $OwnerCopyWith get copyWith => throw _privateConstructorUsedError; 32 | } 33 | 34 | /// @nodoc 35 | abstract class $OwnerCopyWith<$Res> { 36 | factory $OwnerCopyWith(Owner value, $Res Function(Owner) then) = 37 | _$OwnerCopyWithImpl<$Res>; 38 | $Res call( 39 | {int id, 40 | @JsonKey(name: 'avatar_url') String avatarUrl, 41 | @JsonKey(name: 'html_url') String htmlUrl}); 42 | } 43 | 44 | /// @nodoc 45 | class _$OwnerCopyWithImpl<$Res> implements $OwnerCopyWith<$Res> { 46 | _$OwnerCopyWithImpl(this._value, this._then); 47 | 48 | final Owner _value; 49 | // ignore: unused_field 50 | final $Res Function(Owner) _then; 51 | 52 | @override 53 | $Res call({ 54 | Object? id = freezed, 55 | Object? avatarUrl = freezed, 56 | Object? htmlUrl = freezed, 57 | }) { 58 | return _then(_value.copyWith( 59 | id: id == freezed 60 | ? _value.id 61 | : id // ignore: cast_nullable_to_non_nullable 62 | as int, 63 | avatarUrl: avatarUrl == freezed 64 | ? _value.avatarUrl 65 | : avatarUrl // ignore: cast_nullable_to_non_nullable 66 | as String, 67 | htmlUrl: htmlUrl == freezed 68 | ? _value.htmlUrl 69 | : htmlUrl // ignore: cast_nullable_to_non_nullable 70 | as String, 71 | )); 72 | } 73 | } 74 | 75 | /// @nodoc 76 | abstract class _$$_OwnerCopyWith<$Res> implements $OwnerCopyWith<$Res> { 77 | factory _$$_OwnerCopyWith(_$_Owner value, $Res Function(_$_Owner) then) = 78 | __$$_OwnerCopyWithImpl<$Res>; 79 | @override 80 | $Res call( 81 | {int id, 82 | @JsonKey(name: 'avatar_url') String avatarUrl, 83 | @JsonKey(name: 'html_url') String htmlUrl}); 84 | } 85 | 86 | /// @nodoc 87 | class __$$_OwnerCopyWithImpl<$Res> extends _$OwnerCopyWithImpl<$Res> 88 | implements _$$_OwnerCopyWith<$Res> { 89 | __$$_OwnerCopyWithImpl(_$_Owner _value, $Res Function(_$_Owner) _then) 90 | : super(_value, (v) => _then(v as _$_Owner)); 91 | 92 | @override 93 | _$_Owner get _value => super._value as _$_Owner; 94 | 95 | @override 96 | $Res call({ 97 | Object? id = freezed, 98 | Object? avatarUrl = freezed, 99 | Object? htmlUrl = freezed, 100 | }) { 101 | return _then(_$_Owner( 102 | id: id == freezed 103 | ? _value.id 104 | : id // ignore: cast_nullable_to_non_nullable 105 | as int, 106 | avatarUrl: avatarUrl == freezed 107 | ? _value.avatarUrl 108 | : avatarUrl // ignore: cast_nullable_to_non_nullable 109 | as String, 110 | htmlUrl: htmlUrl == freezed 111 | ? _value.htmlUrl 112 | : htmlUrl // ignore: cast_nullable_to_non_nullable 113 | as String, 114 | )); 115 | } 116 | } 117 | 118 | /// @nodoc 119 | @JsonSerializable() 120 | class _$_Owner implements _Owner { 121 | const _$_Owner( 122 | {required this.id, 123 | @JsonKey(name: 'avatar_url') this.avatarUrl = '', 124 | @JsonKey(name: 'html_url') this.htmlUrl = ''}); 125 | 126 | factory _$_Owner.fromJson(Map json) => 127 | _$$_OwnerFromJson(json); 128 | 129 | @override 130 | final int id; 131 | @override 132 | @JsonKey(name: 'avatar_url') 133 | final String avatarUrl; 134 | @override 135 | @JsonKey(name: 'html_url') 136 | final String htmlUrl; 137 | 138 | @override 139 | String toString() { 140 | return 'Owner(id: $id, avatarUrl: $avatarUrl, htmlUrl: $htmlUrl)'; 141 | } 142 | 143 | @override 144 | bool operator ==(dynamic other) { 145 | return identical(this, other) || 146 | (other.runtimeType == runtimeType && 147 | other is _$_Owner && 148 | const DeepCollectionEquality().equals(other.id, id) && 149 | const DeepCollectionEquality().equals(other.avatarUrl, avatarUrl) && 150 | const DeepCollectionEquality().equals(other.htmlUrl, htmlUrl)); 151 | } 152 | 153 | @JsonKey(ignore: true) 154 | @override 155 | int get hashCode => Object.hash( 156 | runtimeType, 157 | const DeepCollectionEquality().hash(id), 158 | const DeepCollectionEquality().hash(avatarUrl), 159 | const DeepCollectionEquality().hash(htmlUrl)); 160 | 161 | @JsonKey(ignore: true) 162 | @override 163 | _$$_OwnerCopyWith<_$_Owner> get copyWith => 164 | __$$_OwnerCopyWithImpl<_$_Owner>(this, _$identity); 165 | 166 | @override 167 | Map toJson() { 168 | return _$$_OwnerToJson(this); 169 | } 170 | } 171 | 172 | abstract class _Owner implements Owner { 173 | const factory _Owner( 174 | {required final int id, 175 | @JsonKey(name: 'avatar_url') final String avatarUrl, 176 | @JsonKey(name: 'html_url') final String htmlUrl}) = _$_Owner; 177 | 178 | factory _Owner.fromJson(Map json) = _$_Owner.fromJson; 179 | 180 | @override 181 | int get id => throw _privateConstructorUsedError; 182 | @override 183 | @JsonKey(name: 'avatar_url') 184 | String get avatarUrl => throw _privateConstructorUsedError; 185 | @override 186 | @JsonKey(name: 'html_url') 187 | String get htmlUrl => throw _privateConstructorUsedError; 188 | @override 189 | @JsonKey(ignore: true) 190 | _$$_OwnerCopyWith<_$_Owner> get copyWith => 191 | throw _privateConstructorUsedError; 192 | } 193 | --------------------------------------------------------------------------------