diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e69de29 diff --git a/lib/config/theme.dart b/lib/config/theme.dart index 09d5993..ca3fffb 100644 --- a/lib/config/theme.dart +++ b/lib/config/theme.dart @@ -44,9 +44,11 @@ ThemeData theme = ThemeData( ), cardTheme: CardThemeData( margin: EdgeInsets.zero, - color: backgroundColor, + color: backgroundLightColor, + shadowColor: Colors.grey, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), + side: BorderSide(color: Colors.transparent, width: 0) ), ), snackBarTheme: const SnackBarThemeData( diff --git a/lib/main.dart b/lib/main.dart index 7b7f5b6..15f8ef8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,122 +1,26 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_web_plugins/url_strategy.dart'; +import 'package:interns2025b_analyzer/config/theme.dart'; +import 'package:interns2025b_analyzer/src/core/routes/router_provider.dart'; void main() { - runApp(const MyApp()); + usePathUrlStrategy(); + runApp(ProviderScope(child: const MyApp())); } -class MyApp extends StatelessWidget { +class MyApp extends ConsumerWidget { const MyApp({super.key}); - // This widget is the root of your application. @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Flutter Demo', - theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. - colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), - ), - home: const MyHomePage(title: 'Flutter Demo Home Page'), - ); - } -} - -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - final String title; - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; - }); - } - - @override - Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('You have pushed the button this many times:'), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, - ), - ], - ), - ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), - ), // This trailing comma makes auto-formatting nicer for build methods. + Widget build(BuildContext context, WidgetRef ref) { + final router = ref.watch(routerProvider); + + return MaterialApp.router( + debugShowCheckedModeBanner: false, + routerConfig: router, + theme: theme, + title: 'Interns2025b Analyzer', ); } } diff --git a/lib/src/core/network/http_client.dart b/lib/src/core/network/network_service.dart similarity index 86% rename from lib/src/core/network/http_client.dart rename to lib/src/core/network/network_service.dart index 9e44e7e..aa322d1 100644 --- a/lib/src/core/network/http_client.dart +++ b/lib/src/core/network/network_service.dart @@ -1,34 +1,18 @@ import 'dart:convert'; import 'dart:io' as io; import 'package:dio/dio.dart'; -import 'package:dio/io.dart'; import 'package:flutter/cupertino.dart'; import '../exceptions/http_exception.dart'; import '../exceptions/message_exception.dart'; import '../exceptions/no_internet_exception.dart'; -class HttpClient { +class NetworkService { final Dio _dio; final String baseUrl; - final String rootPem; - HttpClient({Dio? dio, required this.baseUrl, this.rootPem = ''}) - : _dio = dio ?? Dio(BaseOptions(baseUrl: baseUrl)) { - _initAdapter(); - } - - void _initAdapter() { - (_dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () { - final client = io.HttpClient(); - client.badCertificateCallback = - (io.X509Certificate cert, String host, int port) { - //return cert.pem == rootPem; - return true; - }; - return client; - }; - } + NetworkService({Dio? dio, required this.baseUrl}) + : _dio = dio ?? Dio(BaseOptions(baseUrl: baseUrl)); Future> postRequest({ Map body = const {}, @@ -81,8 +65,9 @@ class HttpClient { Map? queryParams, }) async { try { + final url = '$baseUrl/$urlPath'; final response = await _dio.get>( - '/$urlPath', + url, queryParameters: queryParams, options: Options(headers: await _getHeaders()), ); @@ -104,7 +89,6 @@ class HttpClient { return { 'Content-Type': 'application/json', 'Accept': 'application/json', - 'X-Client-Platform': 'mobile', }; } diff --git a/lib/src/core/network/network_service_provider.dart b/lib/src/core/network/network_service_provider.dart new file mode 100644 index 0000000..2b1ea99 --- /dev/null +++ b/lib/src/core/network/network_service_provider.dart @@ -0,0 +1,18 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'network_service.dart'; + +part 'network_service_provider.g.dart'; + +@riverpod +Dio dio(Ref ref) { + return Dio(); +} + +@riverpod +NetworkService networkService(Ref ref) { + final dioClient = ref.watch(dioProvider); + return NetworkService(dio: dioClient, baseUrl: 'https://api.github.com'); +} diff --git a/lib/src/core/network/network_service_provider.g.dart b/lib/src/core/network/network_service_provider.g.dart new file mode 100644 index 0000000..a0a6659 --- /dev/null +++ b/lib/src/core/network/network_service_provider.g.dart @@ -0,0 +1,44 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'network_service_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$dioHash() => r'a03da399b44b3740dc4fcfc6716203041d66ff01'; + +/// See also [dio]. +@ProviderFor(dio) +final dioProvider = AutoDisposeProvider.internal( + dio, + name: r'dioProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$dioHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef DioRef = AutoDisposeProviderRef; +String _$networkServiceHash() => r'd1eb9057ec45e3932d590bb43ec5c69664f486df'; + +/// See also [networkService]. +@ProviderFor(networkService) +final networkServiceProvider = AutoDisposeProvider.internal( + networkService, + name: r'networkServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$networkServiceHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef NetworkServiceRef = AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/src/core/routes/route_names.dart b/lib/src/core/routes/route_names.dart new file mode 100644 index 0000000..eb6841c --- /dev/null +++ b/lib/src/core/routes/route_names.dart @@ -0,0 +1,2 @@ +const String homeRoute = '/'; +const String selectRepositoryRoute = '/repository/:owner/:name'; \ No newline at end of file diff --git a/lib/src/core/routes/router_provider.dart b/lib/src/core/routes/router_provider.dart new file mode 100644 index 0000000..91ddd1e --- /dev/null +++ b/lib/src/core/routes/router_provider.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:interns2025b_analyzer/src/core/routes/route_names.dart'; + +import '../../feature/select_repository/presentation/pages/select_repository_page.dart'; + +final routerProvider = Provider((ref) { + return GoRouter( + routes: [ + ShellRoute( + builder: (context, state, child) { + return Scaffold( + appBar: AppBar(title: const Text('Interns2025b Analyzer')), + body: child, + ); + }, + routes: [ + GoRoute( + path: homeRoute, + builder: (context, state) => SelectRepositoryPage(), + ), + GoRoute( + path: selectRepositoryRoute, + builder: (context, state) { + final owner = state.pathParameters['owner']!; + final repository = state.pathParameters['name']!; + return SelectRepositoryPage( + ownerName: owner, + repositoryName: repository, + ); + }, + ), + ], + ), + ], + ); +}); diff --git a/lib/src/feature/select_repository/data/data_source/repository_remote_datasource.dart b/lib/src/feature/select_repository/data/data_source/repository_remote_datasource.dart new file mode 100644 index 0000000..090e27a --- /dev/null +++ b/lib/src/feature/select_repository/data/data_source/repository_remote_datasource.dart @@ -0,0 +1,31 @@ +import 'package:interns2025b_analyzer/src/core/exceptions/http_exception.dart'; +import 'package:interns2025b_analyzer/src/core/exceptions/message_exception.dart'; +import 'package:interns2025b_analyzer/src/core/network/network_service.dart'; +import 'package:interns2025b_analyzer/src/feature/select_repository/data/models/repository_model.dart'; + +abstract class RepositoryRemoteDataSource { + Future fetchRepository(String ownerName, String repositoryName); +} + +class RepositoryRemoteDataSourceImpl implements RepositoryRemoteDataSource { + final NetworkService networkService; + + RepositoryRemoteDataSourceImpl(this.networkService); + + @override + Future fetchRepository(String ownerName, String repositoryName) async { + + try{ + final response = await networkService.getRequest( + urlPath: 'repos/$ownerName/$repositoryName', + ); + + return RepositoryModel.fromJson(response); + } on HttpException catch (_) { + rethrow; + } on Exception + catch (_) { + throw MessageException("Something went wrong while fetching repository data. Try again later."); + } + } +} diff --git a/lib/src/feature/select_repository/data/data_source/repository_remote_datasource_provider.dart b/lib/src/feature/select_repository/data/data_source/repository_remote_datasource_provider.dart new file mode 100644 index 0000000..80f9793 --- /dev/null +++ b/lib/src/feature/select_repository/data/data_source/repository_remote_datasource_provider.dart @@ -0,0 +1,13 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:interns2025b_analyzer/src/feature/select_repository/data/data_source/repository_remote_datasource.dart'; + +import '../../../../core/network/network_service_provider.dart'; + +part 'repository_remote_datasource_provider.g.dart'; + +@riverpod +RepositoryRemoteDataSource repositoryRemoteDataSource(Ref ref) { + final networkService = ref.watch(networkServiceProvider); + return RepositoryRemoteDataSourceImpl(networkService); +} \ No newline at end of file diff --git a/lib/src/feature/select_repository/data/data_source/repository_remote_datasource_provider.g.dart b/lib/src/feature/select_repository/data/data_source/repository_remote_datasource_provider.g.dart new file mode 100644 index 0000000..1b95d9b --- /dev/null +++ b/lib/src/feature/select_repository/data/data_source/repository_remote_datasource_provider.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'repository_remote_datasource_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$repositoryRemoteDataSourceHash() => + r'b4a83b74d4fa54974d4aff250814738cbf12533a'; + +/// See also [repositoryRemoteDataSource]. +@ProviderFor(repositoryRemoteDataSource) +final repositoryRemoteDataSourceProvider = + AutoDisposeProvider.internal( + repositoryRemoteDataSource, + name: r'repositoryRemoteDataSourceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$repositoryRemoteDataSourceHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef RepositoryRemoteDataSourceRef = + AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/src/feature/select_repository/data/models/repository_model.dart b/lib/src/feature/select_repository/data/models/repository_model.dart new file mode 100644 index 0000000..184fd71 --- /dev/null +++ b/lib/src/feature/select_repository/data/models/repository_model.dart @@ -0,0 +1,47 @@ +import '../../domain/entities/repository.dart'; + +class RepositoryModel { + final String name; + final String description; + final String ownerName; + final String ownerAvatarUrl; + final int stargazersCount; + final int watchersCount; + final int forksCount; + final int openIssuesCount; + + RepositoryModel({ + required this.name, + required this.description, + required this.ownerName, + required this.ownerAvatarUrl, + required this.stargazersCount, + required this.watchersCount, + required this.forksCount, + required this.openIssuesCount, + }); + + factory RepositoryModel.fromJson(Map json) { + return RepositoryModel( + name: json['name'], + description: json['description'] ?? 'No description, website, or topics provided.', + ownerName: json['owner']['login'], + ownerAvatarUrl: json['owner']['avatar_url'], + stargazersCount: json['stargazers_count'], + watchersCount: json['subscribers_count'], + forksCount: json['forks_count'], + openIssuesCount: json['open_issues_count'], + ); + } + + Repository toEntity() => Repository( + name: name, + description: description, + ownerName: ownerName, + ownerAvatarUrl: ownerAvatarUrl, + stargazersCount: stargazersCount, + watchersCount: watchersCount, + forksCount: forksCount, + openIssuesCount: openIssuesCount, + ); +} \ No newline at end of file diff --git a/lib/src/feature/select_repository/data/repositories/repository_repository_impl.dart b/lib/src/feature/select_repository/data/repositories/repository_repository_impl.dart new file mode 100644 index 0000000..5271a87 --- /dev/null +++ b/lib/src/feature/select_repository/data/repositories/repository_repository_impl.dart @@ -0,0 +1,19 @@ +import '../../domain/entities/repository.dart'; +import '../../domain/repositories/repository_repository.dart'; +import '../data_source/repository_remote_datasource.dart'; + + +class RepositoryRepositoryImpl implements RepositoryRepository { + final RepositoryRemoteDataSource remoteDataSource; + + RepositoryRepositoryImpl({ + required this.remoteDataSource, + }); + + @override + Future fetchRepository(String ownerName, String repositoryName) async { + final repository = await remoteDataSource.fetchRepository(ownerName, repositoryName); + + return repository.toEntity(); + } +} \ No newline at end of file diff --git a/lib/src/feature/select_repository/data/repositories/repository_repository_provider.dart b/lib/src/feature/select_repository/data/repositories/repository_repository_provider.dart new file mode 100644 index 0000000..94a7d05 --- /dev/null +++ b/lib/src/feature/select_repository/data/repositories/repository_repository_provider.dart @@ -0,0 +1,13 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../../domain/repositories/repository_repository.dart'; +import '../data_source/repository_remote_datasource_provider.dart'; +import 'repository_repository_impl.dart'; + +part 'repository_repository_provider.g.dart'; + +@riverpod +RepositoryRepository repositoryRepository(Ref ref) { + final remote = ref.watch(repositoryRemoteDataSourceProvider); + return RepositoryRepositoryImpl(remoteDataSource: remote); +} diff --git a/lib/src/feature/select_repository/data/repositories/repository_repository_provider.g.dart b/lib/src/feature/select_repository/data/repositories/repository_repository_provider.g.dart new file mode 100644 index 0000000..d05ff80 --- /dev/null +++ b/lib/src/feature/select_repository/data/repositories/repository_repository_provider.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'repository_repository_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$repositoryRepositoryHash() => + r'6e8e05ce10a058d4bcb41d4c2416cea51d743146'; + +/// See also [repositoryRepository]. +@ProviderFor(repositoryRepository) +final repositoryRepositoryProvider = + AutoDisposeProvider.internal( + repositoryRepository, + name: r'repositoryRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$repositoryRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef RepositoryRepositoryRef = AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/src/feature/select_repository/domain/entities/repository.dart b/lib/src/feature/select_repository/domain/entities/repository.dart new file mode 100644 index 0000000..9a3a5dc --- /dev/null +++ b/lib/src/feature/select_repository/domain/entities/repository.dart @@ -0,0 +1,21 @@ +class Repository { + final String name; + final String description; + final String ownerName; + final String ownerAvatarUrl; + final int stargazersCount; + final int watchersCount; + final int forksCount; + final int openIssuesCount; + + Repository({ + required this.name, + required this.description, + required this.ownerName, + required this.ownerAvatarUrl, + required this.stargazersCount, + required this.watchersCount, + required this.forksCount, + required this.openIssuesCount, + }); +} \ No newline at end of file diff --git a/lib/src/feature/select_repository/domain/repositories/repository_repository.dart b/lib/src/feature/select_repository/domain/repositories/repository_repository.dart new file mode 100644 index 0000000..54e0686 --- /dev/null +++ b/lib/src/feature/select_repository/domain/repositories/repository_repository.dart @@ -0,0 +1,5 @@ +import 'package:interns2025b_analyzer/src/feature/select_repository/domain/entities/repository.dart'; + +abstract class RepositoryRepository { + Future fetchRepository(String ownerName, String repositoryName); +} \ No newline at end of file diff --git a/lib/src/feature/select_repository/domain/usecases/get_repository_usecase.dart b/lib/src/feature/select_repository/domain/usecases/get_repository_usecase.dart new file mode 100644 index 0000000..90e081c --- /dev/null +++ b/lib/src/feature/select_repository/domain/usecases/get_repository_usecase.dart @@ -0,0 +1,13 @@ +import 'package:interns2025b_analyzer/src/feature/select_repository/domain/entities/repository.dart'; + +import '../repositories/repository_repository.dart'; + +class GetRepositoryUseCase { + final RepositoryRepository repository; + + GetRepositoryUseCase(this.repository); + + Future execute(String ownerName, String repositoryName) { + return repository.fetchRepository(ownerName, repositoryName); + } +} diff --git a/lib/src/feature/select_repository/domain/usecases/get_repository_usecase_provider.dart b/lib/src/feature/select_repository/domain/usecases/get_repository_usecase_provider.dart new file mode 100644 index 0000000..806dce4 --- /dev/null +++ b/lib/src/feature/select_repository/domain/usecases/get_repository_usecase_provider.dart @@ -0,0 +1,12 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:interns2025b_analyzer/src/feature/select_repository/domain/usecases/get_repository_usecase.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../../data/repositories/repository_repository_provider.dart'; + +part 'get_repository_usecase_provider.g.dart'; + +@riverpod +GetRepositoryUseCase getRepositoryUseCase(Ref ref) { + final repository = ref.watch(repositoryRepositoryProvider); + return GetRepositoryUseCase(repository); +} diff --git a/lib/src/feature/select_repository/domain/usecases/get_repository_usecase_provider.g.dart b/lib/src/feature/select_repository/domain/usecases/get_repository_usecase_provider.g.dart new file mode 100644 index 0000000..6a01be0 --- /dev/null +++ b/lib/src/feature/select_repository/domain/usecases/get_repository_usecase_provider.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'get_repository_usecase_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$getRepositoryUseCaseHash() => + r'fe21c1c7bb067db79d8bcaf1140850f1a5b996ab'; + +/// See also [getRepositoryUseCase]. +@ProviderFor(getRepositoryUseCase) +final getRepositoryUseCaseProvider = + AutoDisposeProvider.internal( + getRepositoryUseCase, + name: r'getRepositoryUseCaseProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$getRepositoryUseCaseHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef GetRepositoryUseCaseRef = AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/src/feature/select_repository/presentation/controllers/repository_controller.dart b/lib/src/feature/select_repository/presentation/controllers/repository_controller.dart new file mode 100644 index 0000000..cc5d6ba --- /dev/null +++ b/lib/src/feature/select_repository/presentation/controllers/repository_controller.dart @@ -0,0 +1,41 @@ +import 'package:interns2025b_analyzer/src/core/exceptions/http_exception.dart'; +import 'package:interns2025b_analyzer/src/feature/select_repository/domain/entities/repository.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../../domain/usecases/get_repository_usecase_provider.dart'; + +part 'repository_controller.g.dart'; + +@riverpod +class RepositoryController extends _$RepositoryController { + @override + FutureOr build() { + return null; + } + + Future fetch(String owner, String repo) async { + state = const AsyncLoading(); + try { + final useCase = ref.watch(getRepositoryUseCaseProvider); + final repository = await useCase.execute(owner, repo); + + state = AsyncData(repository); + } on HttpException catch (e) { + if (e.statusCode == 404) { + state = AsyncError( + 'Repository not found. Please check the owner and repository name.', + StackTrace.current, + ); + } else { + state = AsyncError( + 'An unexpected network error occurred. Please try again later.', + StackTrace.current, + ); + } + } catch (e) { + state = AsyncError( + 'An unknown error occurred.', + StackTrace.current, + ); + } + } +} \ No newline at end of file diff --git a/lib/src/feature/select_repository/presentation/controllers/repository_controller.g.dart b/lib/src/feature/select_repository/presentation/controllers/repository_controller.g.dart new file mode 100644 index 0000000..e1c6ded --- /dev/null +++ b/lib/src/feature/select_repository/presentation/controllers/repository_controller.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'repository_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$repositoryControllerHash() => + r'77654ec2f9517dddc877c3bc5789e848a84a05fd'; + +/// See also [RepositoryController]. +@ProviderFor(RepositoryController) +final repositoryControllerProvider = + AutoDisposeAsyncNotifierProvider< + RepositoryController, + Repository? + >.internal( + RepositoryController.new, + name: r'repositoryControllerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$repositoryControllerHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$RepositoryController = AutoDisposeAsyncNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/src/feature/select_repository/presentation/pages/select_repository_page.dart b/lib/src/feature/select_repository/presentation/pages/select_repository_page.dart new file mode 100644 index 0000000..51dde44 --- /dev/null +++ b/lib/src/feature/select_repository/presentation/pages/select_repository_page.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:interns2025b_analyzer/src/feature/select_repository/presentation/controllers/repository_controller.dart'; +import 'package:interns2025b_analyzer/src/feature/select_repository/presentation/widgets/repository_card_widget.dart'; + +class SelectRepositoryPage extends ConsumerStatefulWidget { + final String? ownerName; + final String? repositoryName; + + const SelectRepositoryPage({super.key, this.ownerName, this.repositoryName}); + + @override + ConsumerState createState() => + _SelectRepositoryPageState(); +} + +class _SelectRepositoryPageState extends ConsumerState { + @override + void initState() { + super.initState(); + final ownerName = widget.ownerName ?? "blumilksoftware"; + final repositoryName = widget.repositoryName ?? "interns2025b-mobile"; + + WidgetsBinding.instance.addPostFrameCallback((_) { + ref + .read(repositoryControllerProvider.notifier) + .fetch(ownerName, repositoryName); + }); + } + + @override + Widget build(BuildContext context) { + final repositoryAsync = ref.watch(repositoryControllerProvider); + + return Center( + child: repositoryAsync.when( + data: (repository) { + if (repository == null) { + return const CircularProgressIndicator(); + } + + return RepositoryCard( + repositoryName: repository.name, + repositoryDescription: repository.description, + imageUrl: repository.ownerAvatarUrl, + ownerName: repository.ownerName, + stargazersCount: repository.stargazersCount, + watchersCount: repository.watchersCount, + forksCount: repository.forksCount, + openIssuesCount: repository.openIssuesCount, + ); + }, + error: (e, _) => Center( + child: Text( + e.toString(), + ), + ), + loading: () => const CircularProgressIndicator(), + ), + ); + } +} diff --git a/lib/src/feature/select_repository/presentation/widgets/repository_card_widget.dart b/lib/src/feature/select_repository/presentation/widgets/repository_card_widget.dart new file mode 100644 index 0000000..19a0390 --- /dev/null +++ b/lib/src/feature/select_repository/presentation/widgets/repository_card_widget.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; + +import '../../../../../config/colors.dart'; + +class RepositoryCard extends StatelessWidget { + final String imageUrl; + final String ownerName; + final String repositoryName; + final String repositoryDescription; + final int stargazersCount; + final int watchersCount; + final int forksCount; + final int openIssuesCount; + + const RepositoryCard({ + super.key, + required this.repositoryName, + required this.repositoryDescription, + required this.imageUrl, + required this.ownerName, + required this.stargazersCount, + required this.watchersCount, + required this.forksCount, + required this.openIssuesCount, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10.0), + ), + child: SizedBox( + width: 400, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: ClipOval( + child: Semantics( + label: 'Avatar of $ownerName', + child: Image.network(imageUrl, width: 20, height: 20), + ), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + ownerName, + softWrap: true, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Align( + alignment: Alignment.topLeft, + child: Text( + repositoryName, + softWrap: true, + overflow: TextOverflow.ellipsis, + ), + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Align( + alignment: Alignment.topLeft, + child: Text( + repositoryDescription, + maxLines: 5, + softWrap: true, + overflow: TextOverflow.ellipsis, + ), + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Align( + alignment: Alignment.centerLeft, + child: Container( + constraints: BoxConstraints(maxWidth: 300), + child: Wrap( + spacing: 24, + runSpacing: 8, + children: [ + _buildStatItem(Icons.star, stargazersCount.toString()), + _buildStatItem( + Icons.remove_red_eye_rounded, + watchersCount.toString(), + ), + _buildStatItem(Icons.fork_right, forksCount.toString()), + _buildStatItem( + Icons.warning_amber_rounded, + openIssuesCount.toString(), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildStatItem(IconData icon, String label) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: secondaryTextColor, size: 16), + Flexible( + child: Padding( + padding: const EdgeInsets.only(left: 4.0), + child: Text(label, softWrap: true, overflow: TextOverflow.ellipsis), + ), + ), + ], + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 0fe248d..8736a12 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -295,7 +295,7 @@ packages: source: hosted version: "5.0.0" flutter_riverpod: - dependency: transitive + dependency: "direct main" description: name: flutter_riverpod sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" @@ -307,6 +307,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" freezed_annotation: dependency: transitive description: @@ -331,6 +336,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: c489908a54ce2131f1d1b7cc631af9c1a06fac5ca7c449e959192089f9489431 + url: "https://pub.dev" + source: hosted + version: "16.0.0" graphs: dependency: transitive description: @@ -746,4 +759,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.8.1 <4.0.0" - flutter: ">=3.21.0-13.0.pre.4" + flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml index e7814fd..8bdc9a0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,10 @@ dependencies: hooks_riverpod: ^2.6.1 flutter_hooks: ^0.21.2 riverpod_annotation: ^2.6.1 + flutter_riverpod: ^2.6.1 + go_router: ^16.0.0 + flutter_web_plugins: + sdk: flutter dev_dependencies: flutter_test: