implemented mock crypto repository

changed Cupertino search bar to Material UI 3
changed baseline color and card colors
implemented fetching crypto data from public API (untested)
This commit is contained in:
ShabOl 2024-12-15 23:23:00 +04:00
parent efdbe85897
commit a676d454ca
24 changed files with 363 additions and 77 deletions

View File

@ -1,7 +1,7 @@
{
"@@locale": "en",
"appBarTitle": "Crypto Exchange",
"appBarTitle": "Harry Potter characters",
"search": "Search",
"liked": "You liked",

View File

@ -1,7 +1,7 @@
{
"@@locale": "ru",
"appBarTitle": "Криптобиржа",
"appBarTitle": "Персонажи из Гарри Поттера",
"search": "Поиск",
"liked": "Вы добавили в избранное",

View File

@ -98,7 +98,7 @@ abstract class AppLocale {
/// No description provided for @appBarTitle.
///
/// In ru, this message translates to:
/// **'Криптобиржа'**
/// **'Персонажи из Гарри Поттера'**
String get appBarTitle;
/// No description provided for @search.

View File

@ -7,7 +7,7 @@ class AppLocaleEn extends AppLocale {
AppLocaleEn([String locale = 'en']) : super(locale);
@override
String get appBarTitle => 'Crypto Exchange';
String get appBarTitle => 'Harry Potter characters';
@override
String get search => 'Search';

View File

@ -7,7 +7,7 @@ class AppLocaleRu extends AppLocale {
AppLocaleRu([String locale = 'ru']) : super(locale);
@override
String get appBarTitle => 'Криптобиржа';
String get appBarTitle => 'Персонажи из Гарри Поттера';
@override
String get search => 'Поиск';

View File

@ -0,0 +1,31 @@
import 'package:json_annotation/json_annotation.dart';
part 'coins_dto.g.dart';
@JsonSerializable(createToJson: false)
class CoinsDto {
final List<CoinDataDto>? coins;
CoinsDto({this.coins});
factory CoinsDto.fromJson(Map<String, dynamic> json) => _$CoinsDtoFromJson(json);
}
@JsonSerializable(createToJson: false)
class CoinDataDto {
final String? id;
final String? name;
final String? image;
final double? currentPrice;
final double? priceChange24h;
CoinDataDto({
this.id,
this.name,
this.image,
this.currentPrice,
this.priceChange24h,
});
factory CoinDataDto.fromJson(Map<String, dynamic> json) => _$CoinDataDtoFromJson(json);
}

View File

@ -0,0 +1,21 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'coins_dto.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CoinsDto _$CoinsDtoFromJson(Map<String, dynamic> json) => CoinsDto(
coins: (json['coins'] as List<dynamic>?)
?.map((e) => CoinDataDto.fromJson(e as Map<String, dynamic>))
.toList(),
);
CoinDataDto _$CoinDataDtoFromJson(Map<String, dynamic> json) => CoinDataDto(
id: json['id'] as String?,
name: json['name'] as String?,
image: json['image'] as String?,
currentPrice: (json['currentPrice'] as num?)?.toDouble(),
priceChange24h: (json['priceChange24h'] as num?)?.toDouble(),
);

View File

@ -0,0 +1,21 @@
import 'package:json_annotation/json_annotation.dart';
part 'search_coins_dto.g.dart';
@JsonSerializable(createToJson: false)
class SearchCoinsDto {
final List<SearchCoinDto>? coins;
const SearchCoinsDto({this.coins});
factory SearchCoinsDto.fromJson(Map<String, dynamic> json) => _$SearchCoinsDtoFromJson(json);
}
@JsonSerializable(createToJson: false)
class SearchCoinDto {
final String? id;
const SearchCoinDto({this.id});
factory SearchCoinDto.fromJson(Map<String, dynamic> json) => _$SearchCoinDtoFromJson(json);
}

View File

@ -0,0 +1,19 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'search_coins_dto.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SearchCoinsDto _$SearchCoinsDtoFromJson(Map<String, dynamic> json) =>
SearchCoinsDto(
coins: (json['coins'] as List<dynamic>?)
?.map((e) => SearchCoinDto.fromJson(e as Map<String, dynamic>))
.toList(),
);
SearchCoinDto _$SearchCoinDtoFromJson(Map<String, dynamic> json) =>
SearchCoinDto(
id: json['id'] as String?,
);

View File

@ -5,6 +5,7 @@ import '../../domain/models/home.dart';
const _imagePlaceholder = 'https://gryazoveckij-r19.gosweb.gosuslugi.ru/netcat_files/460/2008/net_foto_muzh.jpg';
/*
extension CharactersDataDto on CharactersDto {
HomeData toDomain() => HomeData(
data: data?.map((e) => e.toDomain()).toList(),
@ -30,3 +31,4 @@ String _makeDescriptionText(String? born, String? died) {
? 'died: $died'
: '';
}
*/

View File

@ -0,0 +1,47 @@
import 'package:flutter_android_app/components/locale/l10n/app_localizations.dart';
import 'package:flutter_android_app/data/dtos/coins_dto.dart';
import 'package:flutter_android_app/domain/models/card.dart';
import 'package:flutter_android_app/domain/models/home.dart';
extension CoinDataDtoToModel on CoinDataDto {
CardData toDomain(AppLocale? locale) => CardData(
id: id ?? 'UNKNOWN',
title: name ?? 'UNKNOWN',
imageUrl: image,
currentPrice: _getLocalizedPrice(currentPrice, locale?.localeName),
priceChange: _getLocalizedPriceChange(priceChange24h, locale),
);
String _getLocalizedPrice(double? price, String? localeName) {
if (localeName == null) {
return '$price \$';
}
return switch (localeName) {
'ru' => '$price',
_ => '$price \$',
};
}
String _getLocalizedPriceChange(double? priceChange, AppLocale? locale) {
if (priceChange == null) {
return '+${_getLocalizedPrice(0, locale?.localeName)}';
}
String retVal = '';
if (priceChange >= 0.0) {
retVal += '+';
}
retVal += _getLocalizedPrice(priceChange, locale?.localeName);
return '$retVal за последние 24 часа';
}
}
extension CoinsDtoToModel on CoinsDto {
HomeData toDomain(AppLocale? locale) => HomeData(
data: coins?.map((e) => e.toDomain(locale)).toList(),
nextPage: 1,
);
}

View File

@ -1,17 +1,17 @@
import 'package:flutter/material.dart';
class CardData {
final String id;
final String title;
final String description;
final IconData icon;
final String? imageUrl;
final String? id;
final String currentPrice;
final String priceChange;
CardData({
required this.id,
required this.title,
required this.description,
this.icon = Icons.adb,
this.imageUrl,
this.id,
required this.currentPrice,
required this.priceChange,
});
}

View File

@ -6,7 +6,7 @@ import 'package:flutter_android_app/presentation/home_page/home_page.dart';
import 'package:flutter_android_app/presentation/like_bloc/like_bloc.dart';
import 'package:flutter_android_app/presentation/locale_bloc/locale_bloc.dart';
import 'package:flutter_android_app/presentation/locale_bloc/locale_state.dart';
import 'package:flutter_android_app/repositories/potter_repository.dart';
import 'package:flutter_android_app/repositories/mock_repository.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'components/locale/l10n/app_localizations.dart';
@ -28,21 +28,22 @@ class MyApp extends StatelessWidget {
create: (context) => LocaleBloc(Locale(Platform.localeName)),
child: BlocBuilder<LocaleBloc, LocaleState>(
builder: (context, state) => MaterialApp(
title: 'Flutter Demo',
title: 'Cryptocurrency Exchange App',
locale: state.currentLocale,
localizationsDelegates: AppLocale.localizationsDelegates,
supportedLocales: AppLocale.supportedLocales,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigoAccent),
useMaterial3: true,
),
home: RepositoryProvider<PotterRepository>(
debugShowCheckedModeBanner: false,
home: RepositoryProvider<MockRepository>(
lazy: true,
create: (_) => PotterRepository(),
create: (_) => MockRepository(),
child: BlocProvider<HomeBloc>(
lazy: false,
create: (context) => HomeBloc(context.read<PotterRepository>()),
child: const MyHomePage(title: 'Harry Potter characters'),
create: (context) => HomeBloc(context.read<MockRepository>()),
child: const MyHomePage(title: 'Cryptocurrency Exchange'),
),
),
),

View File

@ -21,7 +21,7 @@ class DetailsPage extends StatelessWidget {
padding: const EdgeInsets.only(bottom: 4),
child: Text(data.title, style: Theme.of(context).textTheme.headlineLarge),
),
Text(data.description, style: Theme.of(context).textTheme.bodyLarge),
Text(data.currentPrice, style: Theme.of(context).textTheme.bodyLarge),
],
),
);

View File

@ -1,10 +1,10 @@
import 'package:flutter_android_app/presentation/home_page/bloc/events.dart';
import 'package:flutter_android_app/presentation/home_page/bloc/state.dart';
import 'package:flutter_android_app/repositories/potter_repository.dart';
import 'package:flutter_android_app/repositories/api_interface.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class HomeBloc extends Bloc<HomeEvent, HomeState> {
final PotterRepository repo;
final ApiInterface repo;
HomeBloc(this.repo) : super(const HomeState()) {
on<HomeLoadDataEvent>(_onLoadData);
@ -20,7 +20,7 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
String? error;
final data = await repo.loadData(
q: event.search,
search: event.search,
page: event.nextPage ?? 1,
onError: (e) => error = e,
);

View File

@ -3,26 +3,26 @@ part of 'home_page.dart';
typedef OnLikeCallback = void Function(String? id, String title, bool isLiked)?;
class _Card extends StatelessWidget {
final AppLocale locale;
final String id;
final String title;
final String description;
final IconData icon;
final String? imageUrl;
final String currentPrice;
final String priceChange;
final AppLocale locale;
final OnLikeCallback onLike;
final VoidCallback? onTap;
final String? id;
final bool isLiked;
const _Card({
super.key,
required this.locale,
required this.id,
required this.title,
required this.description,
this.icon = Icons.hail,
this.imageUrl,
required this.currentPrice,
required this.priceChange,
required this.locale,
this.onLike,
this.onTap,
this.id,
this.isLiked = false,
});
@ -33,15 +33,15 @@ class _Card extends StatelessWidget {
VoidCallback? onTap,
bool isLiked = false,
}) => _Card(
locale: locale,
id: data.id,
title: data.title,
description: data.description,
icon: data.icon,
imageUrl: data.imageUrl,
currentPrice: data.currentPrice,
priceChange: data.priceChange,
locale: locale,
onLike: onLike,
onTap: onTap,
isLiked: isLiked,
id: data.id,
);
@override
@ -52,7 +52,7 @@ class _Card extends StatelessWidget {
margin: const EdgeInsets.fromLTRB(20, 8, 20, 8),
constraints: const BoxConstraints(minHeight: 140),
decoration: BoxDecoration(
color: Colors.deepPurple.shade200,
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(15),
boxShadow: [
BoxShadow(
@ -94,7 +94,7 @@ class _Card extends StatelessWidget {
style: Theme.of(context).textTheme.headlineLarge,
),
Text(
description,
currentPrice,
style: Theme.of(context).textTheme.bodyLarge),
],
),

View File

@ -17,25 +17,33 @@ import '../like_bloc/like_events.dart';
import '../locale_bloc/locale_bloc.dart';
import '../locale_bloc/locale_events.dart';
import '../locale_bloc/locale_state.dart';
import '../settings_page/settings_page.dart';
part 'card.dart';
class MyHomePage extends StatefulWidget {
class MyHomePage extends StatelessWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.deepPurple.shade200,
title: Text(widget.title),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(title),
actions: [
Padding(
padding: const EdgeInsets.only(right: 12.0),
child: IconButton(
icon: const Icon(Icons.settings),
onPressed: () => Navigator.push(context, MaterialPageRoute<void>(
builder: (BuildContext context) => const SettingsPage(),
)),
),
),
],
),
body: const Body(),
);
@ -79,29 +87,26 @@ class _BodyState extends State<Body> {
flex: 4,
child: Padding(
padding: const EdgeInsets.all(12),
child: CupertinoSearchTextField(
child: SearchBar(
controller: searchController,
onChanged: (search) {
Debounce.run(() => context.read<HomeBloc>().add(HomeLoadDataEvent(search: search)));
},
leading: const Icon(Icons.search),
trailing: [
IconButton(
icon: const Icon(Icons.close),
onPressed: () { searchController.clear(); },
),
],
hintText: context.locale.search,
elevation: const WidgetStatePropertyAll(0.0),
padding: const WidgetStatePropertyAll(EdgeInsets.only(left: 18, right: 10)),
backgroundColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.secondaryContainer),
),
),
),
GestureDetector(
onTap: () => context.read<LocaleBloc>().add(const ChangeLocaleEvent()),
child: SizedBox.square(
dimension: 50,
child: Padding(
padding: const EdgeInsets.only(right: 12),
child: BlocBuilder<LocaleBloc, LocaleState>(
builder: (context, state) {
return state.currentLocale.languageCode == 'ru'
? const SvgRu()
: const SvgUs();
}),
),
),
),
],
),
BlocBuilder<HomeBloc, HomeState>(

View File

@ -3,12 +3,12 @@ import 'like_events.dart';
import 'like_state.dart';
import 'package:shared_preferences/shared_preferences.dart';
const String _likedPrefsKey = 'liked';
class LikeBloc extends Bloc<LikeEvent, LikeState> {
static const String _likedPrefsKey = 'liked';
LikeBloc() : super(const LikeState(likedIds: [])) {
on<ChangeLikeEvent>(_onChangeLike);
on<LoadLikesEvent>(_onLoadLikes);
on<ChangeLikeEvent>(_onChangeLike);
}
Future<void> _onLoadLikes(

View File

@ -8,5 +8,6 @@ class LoadLikesEvent extends LikeEvent {
class ChangeLikeEvent extends LikeEvent {
final String id;
const ChangeLikeEvent(this.id);
}

View File

@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../common/svg_objects.dart';
import '../locale_bloc/locale_bloc.dart';
import '../locale_bloc/locale_events.dart';
import '../locale_bloc/locale_state.dart';
class SettingsPage extends StatelessWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: Center(
child: GestureDetector(
onTap: () => context.read<LocaleBloc>().add(const ChangeLocaleEvent()),
child: SizedBox.square(
dimension: 50,
child: Padding(
padding: const EdgeInsets.only(right: 12),
child: BlocBuilder<LocaleBloc, LocaleState>(
builder: (context, state) {
return state.currentLocale.languageCode == 'ru'
? const SvgRu()
: const SvgUs();
}),
),
),
),
),
);
}
}

View File

@ -1,7 +1,14 @@
import 'package:flutter_android_app/domain/models/home.dart';
import '../components/locale/l10n/app_localizations.dart';
import '../components/utils/error_callback.dart';
abstract class ApiInterface {
Future<HomeData?> loadData({OnErrorCallback? onError});
Future<HomeData?> loadData({
OnErrorCallback? onError,
String? search,
int page = 1,
int pageSize = 20,
AppLocale? locale,
});
}

View File

@ -0,0 +1,84 @@
import 'package:dio/dio.dart';
import 'package:flutter_android_app/components/locale/l10n/app_localizations.dart';
import 'package:flutter_android_app/components/utils/error_callback.dart';
import 'package:flutter_android_app/data/dtos/coins_dto.dart';
import 'package:flutter_android_app/data/dtos/search_coins_dto.dart';
import 'package:flutter_android_app/data/mappers/crypto_mapper.dart';
import 'package:flutter_android_app/repositories/api_interface.dart';
import 'package:pretty_dio_logger/pretty_dio_logger.dart';
import '../domain/models/home.dart';
class CryptoRepository extends ApiInterface {
static const String _baseUrl = 'https://api.coingecko.com/api/v3';
static const String _searchUrl = '/search';
static const String _coinsDataUrl = '/coins/markets';
static const String _apiKey = 'CG-oer6F3AAhVpNxGDxc7mjzZCo';
static final Dio _dio = Dio()
..interceptors.add(PrettyDioLogger(
requestBody: true,
requestHeader: true,
));
@override
Future<HomeData?> loadData({
OnErrorCallback? onError,
String? search,
int page = 1,
int pageSize = 20,
AppLocale? locale,
}) async {
try {
Map<String, dynamic> queryParams = {
'vs_currency': _getCurrencyName(locale?.localeName),
'per_page': pageSize,
'page': page,
};
if (search != null) {
final Response<dynamic> searchResponse = await _dio.get<Map<dynamic, dynamic>>(
'$_baseUrl$_searchUrl',
queryParameters: {
'x_cg_demo_api_key': _apiKey,
'query': search,
}
);
final SearchCoinsDto searchCoinsDto = SearchCoinsDto.fromJson(searchResponse.data as Map<String, dynamic>);
if (searchCoinsDto.coins != null) {
String ids = '';
for (var coinData in searchCoinsDto.coins!) {
ids += coinData.id != null ? '${coinData.id},' : '';
}
queryParams['ids'] = ids;
}
}
final Response<dynamic> response = await _dio.get<Map<dynamic, dynamic>>(
'$_baseUrl$_coinsDataUrl',
queryParameters: queryParams,
);
final CoinsDto dto = CoinsDto.fromJson(response.data as Map<String, dynamic>);
final HomeData data = dto.toDomain(locale);
return data;
} on DioException catch (e) {
onError?.call(e.error?.toString());
return null;
}
}
String _getCurrencyName(String? localeName) {
if (localeName == null) {
return 'usd';
}
return switch (localeName) {
'ru' => 'rub',
_ => 'usd',
};
}
}

View File

@ -1,29 +1,40 @@
import 'package:flutter/material.dart';
import 'package:flutter_android_app/domain/models/card.dart';
import 'package:flutter_android_app/repositories/api_interface.dart';
import '../components/locale/l10n/app_localizations.dart';
import '../components/utils/error_callback.dart';
import '../domain/models/home.dart';
class MockRepository extends ApiInterface {
@override
Future<HomeData?> loadData({OnErrorCallback? onError}) async {
Future<HomeData?> loadData({
OnErrorCallback? onError,
String? search,
int page = 1,
int pageSize = 20,
AppLocale? locale,
}) async {
return HomeData(data: [
CardData(
title: 'Title 1',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
imageUrl: 'https://i.imgur.com/a9WA68S.png',
id: 'bitcoin',
title: 'Bitcoin',
imageUrl: 'https://coin-images.coingecko.com/coins/images/1/large/bitcoin.png?1696501400',
currentPrice: '103233 \$',
priceChange: '+2207.71 \$ for the last 24 hours',
),
CardData(
title: 'Title 2',
description: 'Lorem ipsum dolor sit amet',
icon: Icons.add_chart_outlined,
imageUrl: 'https://i.imgur.com/dAUcs6I.png',
id: 'ethereum',
title: 'Ethereum',
imageUrl: 'https://coin-images.coingecko.com/coins/images/279/large/ethereum.png?1696501628',
currentPrice: '3900.92 \$',
priceChange: '+58.27 \$ for the last 24 hours',
),
CardData(
title: 'Title 3',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor',
imageUrl: 'https://i.imgur.com/m2FhVAK.png',
id: 'tether',
title: 'Tether',
imageUrl: 'https://coin-images.coingecko.com/coins/images/325/large/Tether.png?1696501661',
currentPrice: '1.001 \$',
priceChange: '+0.00059798 \$ for the last 24 hours',
),
]);
}

View File

@ -6,7 +6,7 @@ import 'package:flutter_android_app/repositories/api_interface.dart';
import 'package:pretty_dio_logger/pretty_dio_logger.dart';
import '../components/utils/error_callback.dart';
/*
class PotterRepository extends ApiInterface {
static final Dio _dio = Dio()
..interceptors.add(PrettyDioLogger(
@ -43,3 +43,4 @@ class PotterRepository extends ApiInterface {
}
}
}
*/