Merge branch 'compass-app' of https://github.com/flutter/samples into compass-app

pull/2444/head
Eric Windmill 2 months ago
commit 3c7ee30631

@ -41,3 +41,6 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
# Coverage test report
coverage/

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

@ -44,6 +44,9 @@ void main() {
expect(find.byType(HomeScreen), findsOneWidget);
await tester.pumpAndSettle();
// Should show user name
expect(find.text('Sofie\'s Trips'), findsOneWidget);
// Tap on booking (Alaska is created by default)
await tester.tap(find.text('Alaska, North America'));
await tester.pumpAndSettle();

@ -84,6 +84,9 @@ void main() {
expect(find.byType(HomeScreen), findsOneWidget);
await tester.pumpAndSettle();
// Should show user name
expect(find.text('Sofie\'s Trips'), findsOneWidget);
// Tap on booking (Alaska is created by default)
await tester.tap(find.text('Alaska, North America'));
await tester.pumpAndSettle();

@ -7,6 +7,9 @@ import '../data/repositories/auth/auth_repository_remote.dart';
import '../data/repositories/booking/booking_repository.dart';
import '../data/repositories/booking/booking_repository_local.dart';
import '../data/repositories/booking/booking_repository_remote.dart';
import '../data/repositories/user/user_repository.dart';
import '../data/repositories/user/user_repository_local.dart';
import '../data/repositories/user/user_repository_remote.dart';
import '../data/services/api/auth_api_client.dart';
import '../data/services/local/local_data_service.dart';
import '../data/services/shared_preferences_service.dart';
@ -84,6 +87,11 @@ List<SingleChildWidget> get providersRemote {
apiClient: context.read(),
) as BookingRepository,
),
Provider(
create: (context) => UserRepositoryRemote(
apiClient: context.read(),
) as UserRepository,
),
..._sharedProviders,
];
}
@ -122,6 +130,11 @@ List<SingleChildWidget> get providersLocal {
Provider.value(
value: ItineraryConfigRepositoryMemory() as ItineraryConfigRepository,
),
Provider(
create: (context) => UserRepositoryLocal(
localDataService: context.read(),
) as UserRepository,
),
..._sharedProviders,
];
}

@ -1,5 +1,4 @@
import 'package:compass_model/model.dart';
import '../../../domain/models/activity/activity.dart';
import '../../../utils/result.dart';
/// Data source for activities.

@ -1,5 +1,4 @@
import 'package:compass_model/model.dart';
import '../../../domain/models/activity/activity.dart';
import '../../../utils/result.dart';
import '../../services/local/local_data_service.dart';
import 'activity_repository.dart';

@ -1,5 +1,4 @@
import 'package:compass_model/model.dart';
import '../../../domain/models/activity/activity.dart';
import '../../../utils/result.dart';
import '../../services/api/api_client.dart';
import 'activity_repository.dart';

@ -1,9 +1,10 @@
import 'package:compass_model/model.dart';
import 'package:logging/logging.dart';
import '../../../utils/result.dart';
import '../../services/api/api_client.dart';
import '../../services/api/auth_api_client.dart';
import '../../services/api/model/login_request/login_request.dart';
import '../../services/api/model/login_response/login_response.dart';
import '../../services/shared_preferences_service.dart';
import 'auth_repository.dart';

@ -1,5 +1,4 @@
import 'package:compass_model/model.dart';
import '../../../domain/models/booking/booking.dart';
import '../../../domain/models/booking/booking_summary.dart';
import '../../../utils/result.dart';
@ -12,4 +11,7 @@ abstract class BookingRepository {
/// Creates a new [Booking].
Future<Result<void>> createBooking(Booking booking);
/// Delete booking
Future<Result<void>> delete(int id);
}

@ -1,7 +1,8 @@
import 'dart:async';
import 'package:compass_model/model.dart';
import 'package:collection/collection.dart';
import '../../../domain/models/booking/booking.dart';
import '../../../domain/models/booking/booking_summary.dart';
import '../../../utils/result.dart';
@ -13,37 +14,47 @@ class BookingRepositoryLocal implements BookingRepository {
required LocalDataService localDataService,
}) : _localDataService = localDataService;
// Only create default booking once
bool _isInitialized = false;
// Used to generate IDs for bookings
int _sequentialId = 0;
final _bookings = List<Booking>.empty(growable: true);
final LocalDataService _localDataService;
@override
Future<Result<void>> createBooking(Booking booking) async {
_bookings.add(booking);
// Bookings created come without id, we need to assign one
final bookingWithId = booking.copyWith(id: _sequentialId++);
_bookings.add(bookingWithId);
return Result.ok(null);
}
@override
Future<Result<Booking>> getBooking(int id) async {
await _createDefaultBooking();
if (id >= _bookings.length || id < 0) {
return Result.error(Exception('Invalid id: $id'));
final booking = _bookings.firstWhereOrNull((booking) => booking.id == id);
if (booking == null) {
return Result.error(Exception('Booking not found'));
}
return Result.ok(_bookings[id]);
return Result.ok(booking);
}
@override
Future<Result<List<BookingSummary>>> getBookingsList() async {
await _createDefaultBooking();
// Initialize the repository with a default booking
if (!_isInitialized) {
await _createDefaultBooking();
_isInitialized = true;
}
return Result.ok(_createSummaries());
}
List<BookingSummary> _createSummaries() {
return _bookings
.mapIndexed(
(index, booking) => BookingSummary(
id: index,
.map(
(booking) => BookingSummary(
id: booking.id!,
name:
'${booking.destination.name}, ${booking.destination.continent}',
startDate: booking.startDate,
@ -64,6 +75,7 @@ class BookingRepositoryLocal implements BookingRepository {
_bookings.add(
Booking(
id: _sequentialId++,
startDate: DateTime(2024, 1, 1),
endDate: DateTime(2024, 2, 1),
destination: destination,
@ -72,4 +84,10 @@ class BookingRepositoryLocal implements BookingRepository {
);
}
}
@override
Future<Result<void>> delete(int id) async {
_bookings.removeWhere((booking) => booking.id == id);
return Result.ok(null);
}
}

@ -1,6 +1,7 @@
import 'package:compass_model/model.dart';
import '../../../domain/models/activity/activity.dart';
import '../../../domain/models/booking/booking.dart';
import '../../../domain/models/booking/booking_summary.dart';
import '../../../domain/models/destination/destination.dart';
import '../../../utils/result.dart';
import '../../services/api/api_client.dart';
import '../../services/api/model/booking/booking_api_model.dart';
@ -67,6 +68,7 @@ class BookingRepositoryRemote implements BookingRepository {
return Result.ok(
Booking(
id: booking.id,
startDate: booking.startDate,
endDate: booking.endDate,
destination: destination,
@ -100,4 +102,13 @@ class BookingRepositoryRemote implements BookingRepository {
return Result.error(e);
}
}
@override
Future<Result<void>> delete(int id) async {
try {
return _apiClient.deleteBooking(id);
} on Exception catch (e) {
return Result.error(e);
}
}
}

@ -1,5 +1,4 @@
import 'package:compass_model/model.dart';
import '../../../domain/models/continent/continent.dart';
import '../../../utils/result.dart';
/// Data source with all possible continents.

@ -1,5 +1,4 @@
import 'package:compass_model/model.dart';
import '../../../domain/models/continent/continent.dart';
import '../../../utils/result.dart';
import '../../services/local/local_data_service.dart';
import 'continent_repository.dart';

@ -1,5 +1,4 @@
import 'package:compass_model/model.dart';
import '../../../domain/models/continent/continent.dart';
import '../../../utils/result.dart';
import '../../services/api/api_client.dart';
import 'continent_repository.dart';

@ -1,11 +1,8 @@
import 'package:compass_model/model.dart';
import '../../../domain/models/destination/destination.dart';
import '../../../utils/result.dart';
/// Data source with all possible destinations
abstract class DestinationRepository {
/// Get complete list of destinations
Future<Result<List<Destination>>> getDestinations();
// TODO: Consider creating getByContinent instead of filtering in ViewModel
}

@ -1,5 +1,4 @@
import 'package:compass_model/model.dart';
import '../../../domain/models/destination/destination.dart';
import '../../../utils/result.dart';
import '../../services/local/local_data_service.dart';
import 'destination_repository.dart';

@ -1,5 +1,4 @@
import 'package:compass_model/model.dart';
import '../../../domain/models/destination/destination.dart';
import '../../../utils/result.dart';
import '../../services/api/api_client.dart';
import 'destination_repository.dart';

@ -1,5 +1,4 @@
import 'package:compass_model/model.dart';
import '../../../domain/models/itinerary_config/itinerary_config.dart';
import '../../../utils/result.dart';
/// Data source for the current [ItineraryConfig]

@ -1,7 +1,6 @@
import 'dart:async';
import 'package:compass_model/model.dart';
import '../../../domain/models/itinerary_config/itinerary_config.dart';
import '../../../utils/result.dart';
import 'itinerary_config_repository.dart';

@ -0,0 +1,8 @@
import '../../../domain/models/user/user.dart';
import '../../../utils/result.dart';
/// Data source for user related data
abstract class UserRepository {
/// Get current user
Future<Result<User>> getUser();
}

@ -0,0 +1,17 @@
import '../../../domain/models/user/user.dart';
import '../../../utils/result.dart';
import '../../services/local/local_data_service.dart';
import 'user_repository.dart';
class UserRepositoryLocal implements UserRepository {
UserRepositoryLocal({
required LocalDataService localDataService,
}) : _localDataService = localDataService;
final LocalDataService _localDataService;
@override
Future<Result<User>> getUser() async {
return Result.ok(_localDataService.getUser());
}
}

@ -0,0 +1,35 @@
import '../../../domain/models/user/user.dart';
import '../../../utils/result.dart';
import '../../services/api/api_client.dart';
import '../../services/api/model/user/user_api_model.dart';
import 'user_repository.dart';
class UserRepositoryRemote implements UserRepository {
UserRepositoryRemote({
required ApiClient apiClient,
}) : _apiClient = apiClient;
final ApiClient _apiClient;
User? _cachedData;
@override
Future<Result<User>> getUser() async {
if (_cachedData != null) {
return Future.value(Result.ok(_cachedData!));
}
final result = await _apiClient.getUser();
switch (result) {
case Ok<UserApiModel>():
final user = User(
name: result.value.name,
picture: result.value.picture,
);
_cachedData = user;
return Result.ok(user);
case Error<UserApiModel>():
return Result.error(result.error);
}
}
}

@ -1,16 +1,28 @@
import 'dart:convert';
import 'dart:io';
import 'package:compass_model/model.dart';
import '../../../domain/models/activity/activity.dart';
import '../../../domain/models/continent/continent.dart';
import '../../../domain/models/destination/destination.dart';
import '../../../utils/result.dart';
import 'model/booking/booking_api_model.dart';
import 'model/user/user_api_model.dart';
/// Adds the `Authentication` header to a header configuration.
typedef AuthHeaderProvider = String? Function();
// TODO: Configurable baseurl/host/port
class ApiClient {
ApiClient();
ApiClient({
String? host,
int? port,
HttpClient Function()? clientFactory,
}) : _host = host ?? 'localhost',
_port = port ?? 8080,
_clientFactory = clientFactory ?? (() => HttpClient());
final String _host;
final int _port;
final HttpClient Function() _clientFactory;
AuthHeaderProvider? _authHeaderProvider;
@ -26,9 +38,9 @@ class ApiClient {
}
Future<Result<List<Continent>>> getContinents() async {
final client = HttpClient();
final client = _clientFactory();
try {
final request = await client.get('localhost', 8080, '/continent');
final request = await client.get(_host, _port, '/continent');
await _authHeader(request.headers);
final response = await request.close();
if (response.statusCode == 200) {
@ -47,9 +59,9 @@ class ApiClient {
}
Future<Result<List<Destination>>> getDestinations() async {
final client = HttpClient();
final client = _clientFactory();
try {
final request = await client.get('localhost', 8080, '/destination');
final request = await client.get(_host, _port, '/destination');
await _authHeader(request.headers);
final response = await request.close();
if (response.statusCode == 200) {
@ -68,10 +80,10 @@ class ApiClient {
}
Future<Result<List<Activity>>> getActivityByDestination(String ref) async {
final client = HttpClient();
final client = _clientFactory();
try {
final request =
await client.get('localhost', 8080, '/destination/$ref/activity');
await client.get(_host, _port, '/destination/$ref/activity');
await _authHeader(request.headers);
final response = await request.close();
if (response.statusCode == 200) {
@ -91,9 +103,9 @@ class ApiClient {
}
Future<Result<List<BookingApiModel>>> getBookings() async {
final client = HttpClient();
final client = _clientFactory();
try {
final request = await client.get('localhost', 8080, '/booking');
final request = await client.get(_host, _port, '/booking');
await _authHeader(request.headers);
final response = await request.close();
if (response.statusCode == 200) {
@ -113,9 +125,9 @@ class ApiClient {
}
Future<Result<BookingApiModel>> getBooking(int id) async {
final client = HttpClient();
final client = _clientFactory();
try {
final request = await client.get('localhost', 8080, '/booking/$id');
final request = await client.get(_host, _port, '/booking/$id');
await _authHeader(request.headers);
final response = await request.close();
if (response.statusCode == 200) {
@ -133,9 +145,9 @@ class ApiClient {
}
Future<Result<BookingApiModel>> postBooking(BookingApiModel booking) async {
final client = HttpClient();
final client = _clientFactory();
try {
final request = await client.post('localhost', 8080, '/booking');
final request = await client.post(_host, _port, '/booking');
await _authHeader(request.headers);
request.write(jsonEncode(booking));
final response = await request.close();
@ -152,4 +164,43 @@ class ApiClient {
client.close();
}
}
Future<Result<UserApiModel>> getUser() async {
final client = _clientFactory();
try {
final request = await client.get(_host, _port, '/user');
await _authHeader(request.headers);
final response = await request.close();
if (response.statusCode == 200) {
final stringData = await response.transform(utf8.decoder).join();
final user = UserApiModel.fromJson(jsonDecode(stringData));
return Result.ok(user);
} else {
return Result.error(const HttpException("Invalid response"));
}
} on Exception catch (error) {
return Result.error(error);
} finally {
client.close();
}
}
Future<Result<void>> deleteBooking(int id) async {
final client = _clientFactory();
try {
final request = await client.delete(_host, _port, '/booking/$id');
await _authHeader(request.headers);
final response = await request.close();
// Response 204 "No Content", delete was successful
if (response.statusCode == 204) {
return Result.ok(null);
} else {
return Result.error(const HttpException("Invalid response"));
}
} on Exception catch (error) {
return Result.error(error);
} finally {
client.close();
}
}
}

@ -1,16 +1,27 @@
// TODO: Configurable baseurl/host/port
import 'dart:convert';
import 'dart:io';
import 'package:compass_model/model.dart';
import '../../../utils/result.dart';
import 'model/login_request/login_request.dart';
import 'model/login_response/login_response.dart';
class AuthApiClient {
AuthApiClient({
String? host,
int? port,
HttpClient Function()? clientFactory,
}) : _host = host ?? 'localhost',
_port = port ?? 8080,
_clientFactory = clientFactory ?? (() => HttpClient());
final String _host;
final int _port;
final HttpClient Function() _clientFactory;
Future<Result<LoginResponse>> login(LoginRequest loginRequest) async {
final client = HttpClient();
final client = _clientFactory();
try {
final request = await client.post('localhost', 8080, '/login');
final request = await client.post(_host, _port, '/login');
request.write(jsonEncode(loginRequest));
final response = await request.close();
if (response.statusCode == 200) {

@ -0,0 +1,24 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user_api_model.freezed.dart';
part 'user_api_model.g.dart';
@freezed
abstract class UserApiModel with _$UserApiModel {
const factory UserApiModel({
/// The user's ID.
required String id,
/// The user's name.
required String name,
/// The user's email.
required String email,
/// The user's picture URL.
required String picture,
}) = _UserApiModel;
factory UserApiModel.fromJson(Map<String, Object?> json) =>
_$UserApiModelFromJson(json);
}

@ -0,0 +1,241 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// 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, unnecessary_question_mark
part of 'user_api_model.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'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#adding-getters-and-methods-to-our-models');
UserApiModel _$UserApiModelFromJson(Map<String, dynamic> json) {
return _UserApiModel.fromJson(json);
}
/// @nodoc
mixin _$UserApiModel {
/// The user's ID.
String get id => throw _privateConstructorUsedError;
/// The user's name.
String get name => throw _privateConstructorUsedError;
/// The user's email.
String get email => throw _privateConstructorUsedError;
/// The user's picture URL.
String get picture => throw _privateConstructorUsedError;
/// Serializes this UserApiModel to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of UserApiModel
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$UserApiModelCopyWith<UserApiModel> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $UserApiModelCopyWith<$Res> {
factory $UserApiModelCopyWith(
UserApiModel value, $Res Function(UserApiModel) then) =
_$UserApiModelCopyWithImpl<$Res, UserApiModel>;
@useResult
$Res call({String id, String name, String email, String picture});
}
/// @nodoc
class _$UserApiModelCopyWithImpl<$Res, $Val extends UserApiModel>
implements $UserApiModelCopyWith<$Res> {
_$UserApiModelCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of UserApiModel
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? name = null,
Object? email = null,
Object? picture = null,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
email: null == email
? _value.email
: email // ignore: cast_nullable_to_non_nullable
as String,
picture: null == picture
? _value.picture
: picture // ignore: cast_nullable_to_non_nullable
as String,
) as $Val);
}
}
/// @nodoc
abstract class _$$UserApiModelImplCopyWith<$Res>
implements $UserApiModelCopyWith<$Res> {
factory _$$UserApiModelImplCopyWith(
_$UserApiModelImpl value, $Res Function(_$UserApiModelImpl) then) =
__$$UserApiModelImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({String id, String name, String email, String picture});
}
/// @nodoc
class __$$UserApiModelImplCopyWithImpl<$Res>
extends _$UserApiModelCopyWithImpl<$Res, _$UserApiModelImpl>
implements _$$UserApiModelImplCopyWith<$Res> {
__$$UserApiModelImplCopyWithImpl(
_$UserApiModelImpl _value, $Res Function(_$UserApiModelImpl) _then)
: super(_value, _then);
/// Create a copy of UserApiModel
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? name = null,
Object? email = null,
Object? picture = null,
}) {
return _then(_$UserApiModelImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
email: null == email
? _value.email
: email // ignore: cast_nullable_to_non_nullable
as String,
picture: null == picture
? _value.picture
: picture // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// @nodoc
@JsonSerializable()
class _$UserApiModelImpl implements _UserApiModel {
const _$UserApiModelImpl(
{required this.id,
required this.name,
required this.email,
required this.picture});
factory _$UserApiModelImpl.fromJson(Map<String, dynamic> json) =>
_$$UserApiModelImplFromJson(json);
/// The user's ID.
@override
final String id;
/// The user's name.
@override
final String name;
/// The user's email.
@override
final String email;
/// The user's picture URL.
@override
final String picture;
@override
String toString() {
return 'UserApiModel(id: $id, name: $name, email: $email, picture: $picture)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$UserApiModelImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.name, name) || other.name == name) &&
(identical(other.email, email) || other.email == email) &&
(identical(other.picture, picture) || other.picture == picture));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, id, name, email, picture);
/// Create a copy of UserApiModel
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$UserApiModelImplCopyWith<_$UserApiModelImpl> get copyWith =>
__$$UserApiModelImplCopyWithImpl<_$UserApiModelImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$UserApiModelImplToJson(
this,
);
}
}
abstract class _UserApiModel implements UserApiModel {
const factory _UserApiModel(
{required final String id,
required final String name,
required final String email,
required final String picture}) = _$UserApiModelImpl;
factory _UserApiModel.fromJson(Map<String, dynamic> json) =
_$UserApiModelImpl.fromJson;
/// The user's ID.
@override
String get id;
/// The user's name.
@override
String get name;
/// The user's email.
@override
String get email;
/// The user's picture URL.
@override
String get picture;
/// Create a copy of UserApiModel
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$UserApiModelImplCopyWith<_$UserApiModelImpl> get copyWith =>
throw _privateConstructorUsedError;
}

@ -0,0 +1,23 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'user_api_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$UserApiModelImpl _$$UserApiModelImplFromJson(Map<String, dynamic> json) =>
_$UserApiModelImpl(
id: json['id'] as String,
name: json['name'] as String,
email: json['email'] as String,
picture: json['picture'] as String,
);
Map<String, dynamic> _$$UserApiModelImplToJson(_$UserApiModelImpl instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'email': instance.email,
'picture': instance.picture,
};

@ -1,9 +1,12 @@
import 'dart:convert';
import 'package:compass_model/model.dart';
import 'package:flutter/services.dart';
import '../../../config/assets.dart';
import '../../../domain/models/activity/activity.dart';
import '../../../domain/models/continent/continent.dart';
import '../../../domain/models/destination/destination.dart';
import '../../../domain/models/user/user.dart';
class LocalDataService {
List<Continent> getContinents() {
@ -53,4 +56,12 @@ class LocalDataService {
final localData = await rootBundle.loadString(asset);
return (jsonDecode(localData) as List).cast<Map<String, dynamic>>();
}
User getUser() {
return const User(
name: 'Sofie',
// For demo purposes we use a local asset
picture: 'assets/user.jpg',
);
}
}

@ -1,10 +1,13 @@
import 'package:compass_model/model.dart';
import 'package:logging/logging.dart';
import '../../../data/repositories/activity/activity_repository.dart';
import '../../../data/repositories/booking/booking_repository.dart';
import '../../../data/repositories/destination/destination_repository.dart';
import '../../../utils/result.dart';
import '../../models/activity/activity.dart';
import '../../models/booking/booking.dart';
import '../../models/destination/destination.dart';
import '../../models/itinerary_config/itinerary_config.dart';
/// Component for creating [Booking] objects from [ItineraryConfig].
///

@ -1,10 +1,10 @@
import 'package:compass_model/model.dart';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:share_plus/share_plus.dart';
import '../../../utils/result.dart';
import '../../../ui/core/ui/date_format_start_end.dart';
import '../../models/booking/booking.dart';
typedef ShareFunction = Future<void> Function(String text);

@ -1,12 +1,18 @@
import 'package:compass_model/model.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../activity/activity.dart';
import '../destination/destination.dart';
part 'booking.freezed.dart';
part 'booking.g.dart';
@freezed
class Booking with _$Booking {
const factory Booking({
/// Optional ID of the booking.
/// May be null if the booking is not yet stored.
int? id,
/// Start date of the trip
required DateTime startDate,

@ -20,6 +20,10 @@ Booking _$BookingFromJson(Map<String, dynamic> json) {
/// @nodoc
mixin _$Booking {
/// Optional ID of the booking.
/// May be null if the booking is not yet stored.
int? get id => throw _privateConstructorUsedError;
/// Start date of the trip
DateTime get startDate => throw _privateConstructorUsedError;
@ -47,7 +51,8 @@ abstract class $BookingCopyWith<$Res> {
_$BookingCopyWithImpl<$Res, Booking>;
@useResult
$Res call(
{DateTime startDate,
{int? id,
DateTime startDate,
DateTime endDate,
Destination destination,
List<Activity> activity});
@ -70,12 +75,17 @@ class _$BookingCopyWithImpl<$Res, $Val extends Booking>
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = freezed,
Object? startDate = null,
Object? endDate = null,
Object? destination = null,
Object? activity = null,
}) {
return _then(_value.copyWith(
id: freezed == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int?,
startDate: null == startDate
? _value.startDate
: startDate // ignore: cast_nullable_to_non_nullable
@ -114,7 +124,8 @@ abstract class _$$BookingImplCopyWith<$Res> implements $BookingCopyWith<$Res> {
@override
@useResult
$Res call(
{DateTime startDate,
{int? id,
DateTime startDate,
DateTime endDate,
Destination destination,
List<Activity> activity});
@ -136,12 +147,17 @@ class __$$BookingImplCopyWithImpl<$Res>
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = freezed,
Object? startDate = null,
Object? endDate = null,
Object? destination = null,
Object? activity = null,
}) {
return _then(_$BookingImpl(
id: freezed == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int?,
startDate: null == startDate
? _value.startDate
: startDate // ignore: cast_nullable_to_non_nullable
@ -166,7 +182,8 @@ class __$$BookingImplCopyWithImpl<$Res>
@JsonSerializable()
class _$BookingImpl implements _Booking {
const _$BookingImpl(
{required this.startDate,
{this.id,
required this.startDate,
required this.endDate,
required this.destination,
required final List<Activity> activity})
@ -175,6 +192,11 @@ class _$BookingImpl implements _Booking {
factory _$BookingImpl.fromJson(Map<String, dynamic> json) =>
_$$BookingImplFromJson(json);
/// Optional ID of the booking.
/// May be null if the booking is not yet stored.
@override
final int? id;
/// Start date of the trip
@override
final DateTime startDate;
@ -200,7 +222,7 @@ class _$BookingImpl implements _Booking {
@override
String toString() {
return 'Booking(startDate: $startDate, endDate: $endDate, destination: $destination, activity: $activity)';
return 'Booking(id: $id, startDate: $startDate, endDate: $endDate, destination: $destination, activity: $activity)';
}
@override
@ -208,6 +230,7 @@ class _$BookingImpl implements _Booking {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$BookingImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.startDate, startDate) ||
other.startDate == startDate) &&
(identical(other.endDate, endDate) || other.endDate == endDate) &&
@ -218,8 +241,8 @@ class _$BookingImpl implements _Booking {
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, startDate, endDate, destination,
const DeepCollectionEquality().hash(_activity));
int get hashCode => Object.hash(runtimeType, id, startDate, endDate,
destination, const DeepCollectionEquality().hash(_activity));
/// Create a copy of Booking
/// with the given fields replaced by the non-null parameter values.
@ -239,13 +262,19 @@ class _$BookingImpl implements _Booking {
abstract class _Booking implements Booking {
const factory _Booking(
{required final DateTime startDate,
{final int? id,
required final DateTime startDate,
required final DateTime endDate,
required final Destination destination,
required final List<Activity> activity}) = _$BookingImpl;
factory _Booking.fromJson(Map<String, dynamic> json) = _$BookingImpl.fromJson;
/// Optional ID of the booking.
/// May be null if the booking is not yet stored.
@override
int? get id;
/// Start date of the trip
@override
DateTime get startDate;

@ -8,6 +8,7 @@ part of 'booking.dart';
_$BookingImpl _$$BookingImplFromJson(Map<String, dynamic> json) =>
_$BookingImpl(
id: (json['id'] as num?)?.toInt(),
startDate: DateTime.parse(json['startDate'] as String),
endDate: DateTime.parse(json['endDate'] as String),
destination:
@ -19,6 +20,7 @@ _$BookingImpl _$$BookingImplFromJson(Map<String, dynamic> json) =>
Map<String, dynamic> _$$BookingImplToJson(_$BookingImpl instance) =>
<String, dynamic>{
'id': instance.id,
'startDate': instance.startDate.toIso8601String(),
'endDate': instance.endDate.toIso8601String(),
'destination': instance.destination,

@ -23,10 +23,10 @@ mixin _$BookingSummary {
/// Booking id
int get id => throw _privateConstructorUsedError;
/// Destination name to be displayed.
/// Name to be displayed
String get name => throw _privateConstructorUsedError;
/// Start date of the booking.
/// Start date of the booking
DateTime get startDate => throw _privateConstructorUsedError;
/// End date of the booking
@ -158,11 +158,11 @@ class _$BookingSummaryImpl implements _BookingSummary {
@override
final int id;
/// Destination name to be displayed.
/// Name to be displayed
@override
final String name;
/// Start date of the booking.
/// Start date of the booking
@override
final DateTime startDate;
@ -222,11 +222,11 @@ abstract class _BookingSummary implements BookingSummary {
@override
int get id;
/// Destination name to be displayed.
/// Name to be displayed
@override
String get name;
/// Start date of the booking.
/// Start date of the booking
@override
DateTime get startDate;

@ -0,0 +1,17 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';
part 'user.g.dart';
@freezed
abstract class User with _$User {
const factory User({
/// The user's name.
required String name,
/// The user's picture URL.
required String picture,
}) = _User;
factory User.fromJson(Map<String, Object?> json) => _$UserFromJson(json);
}

@ -0,0 +1,185 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// 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, unnecessary_question_mark
part of 'user.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'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#adding-getters-and-methods-to-our-models');
User _$UserFromJson(Map<String, dynamic> json) {
return _User.fromJson(json);
}
/// @nodoc
mixin _$User {
/// The user's name.
String get name => throw _privateConstructorUsedError;
/// The user's picture URL.
String get picture => throw _privateConstructorUsedError;
/// Serializes this User to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of User
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$UserCopyWith<User> get copyWith => throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $UserCopyWith<$Res> {
factory $UserCopyWith(User value, $Res Function(User) then) =
_$UserCopyWithImpl<$Res, User>;
@useResult
$Res call({String name, String picture});
}
/// @nodoc
class _$UserCopyWithImpl<$Res, $Val extends User>
implements $UserCopyWith<$Res> {
_$UserCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of User
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? name = null,
Object? picture = null,
}) {
return _then(_value.copyWith(
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
picture: null == picture
? _value.picture
: picture // ignore: cast_nullable_to_non_nullable
as String,
) as $Val);
}
}
/// @nodoc
abstract class _$$UserImplCopyWith<$Res> implements $UserCopyWith<$Res> {
factory _$$UserImplCopyWith(
_$UserImpl value, $Res Function(_$UserImpl) then) =
__$$UserImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({String name, String picture});
}
/// @nodoc
class __$$UserImplCopyWithImpl<$Res>
extends _$UserCopyWithImpl<$Res, _$UserImpl>
implements _$$UserImplCopyWith<$Res> {
__$$UserImplCopyWithImpl(_$UserImpl _value, $Res Function(_$UserImpl) _then)
: super(_value, _then);
/// Create a copy of User
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? name = null,
Object? picture = null,
}) {
return _then(_$UserImpl(
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
picture: null == picture
? _value.picture
: picture // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// @nodoc
@JsonSerializable()
class _$UserImpl implements _User {
const _$UserImpl({required this.name, required this.picture});
factory _$UserImpl.fromJson(Map<String, dynamic> json) =>
_$$UserImplFromJson(json);
/// The user's name.
@override
final String name;
/// The user's picture URL.
@override
final String picture;
@override
String toString() {
return 'User(name: $name, picture: $picture)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$UserImpl &&
(identical(other.name, name) || other.name == name) &&
(identical(other.picture, picture) || other.picture == picture));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, name, picture);
/// Create a copy of User
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$UserImplCopyWith<_$UserImpl> get copyWith =>
__$$UserImplCopyWithImpl<_$UserImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$UserImplToJson(
this,
);
}
}
abstract class _User implements User {
const factory _User(
{required final String name, required final String picture}) = _$UserImpl;
factory _User.fromJson(Map<String, dynamic> json) = _$UserImpl.fromJson;
/// The user's name.
@override
String get name;
/// The user's picture URL.
@override
String get picture;
/// Create a copy of User
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$UserImplCopyWith<_$UserImpl> get copyWith =>
throw _privateConstructorUsedError;
}

@ -0,0 +1,18 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'user.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$UserImpl _$$UserImplFromJson(Map<String, dynamic> json) => _$UserImpl(
name: json['name'] as String,
picture: json['picture'] as String,
);
Map<String, dynamic> _$$UserImplToJson(_$UserImpl instance) =>
<String, dynamic>{
'name': instance.name,
'picture': instance.picture,
};

@ -45,6 +45,7 @@ GoRouter router(
builder: (context, state) {
final viewModel = HomeViewModel(
bookingRepository: context.read(),
userRepository: context.read(),
);
return HomeScreen(viewModel: viewModel);
},

@ -1,9 +1,9 @@
import 'package:compass_model/model.dart';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import '../../../data/repositories/activity/activity_repository.dart';
import '../../../data/repositories/itinerary_config/itinerary_config_repository.dart';
import '../../../domain/models/activity/activity.dart';
import '../../../utils/command.dart';
import '../../../utils/result.dart';

@ -1,7 +1,7 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:compass_model/model.dart';
import 'package:flutter/material.dart';
import '../../../domain/models/activity/activity.dart';
import '../../../utils/image_error_listener.dart';
import '../../core/ui/custom_checkbox.dart';

@ -1,7 +1,6 @@
import 'package:compass_model/model.dart';
import '../../../../data/repositories/auth/auth_repository.dart';
import '../../../../data/repositories/itinerary_config/itinerary_config_repository.dart';
import '../../../../domain/models/itinerary_config/itinerary_config.dart';
import '../../../../utils/command.dart';
import '../../../../utils/result.dart';

@ -1,9 +1,10 @@
import 'package:compass_model/model.dart';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import '../../../data/repositories/booking/booking_repository.dart';
import '../../../data/repositories/itinerary_config/itinerary_config_repository.dart';
import '../../../domain/models/booking/booking.dart';
import '../../../domain/models/itinerary_config/itinerary_config.dart';
import '../../../utils/command.dart';
import '../../../utils/result.dart';
import '../../../domain/components/booking/booking_create_component.dart';

@ -1,7 +1,7 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:compass_model/model.dart';
import 'package:flutter/material.dart';
import '../../../domain/models/activity/activity.dart';
import '../../../utils/image_error_listener.dart';
import '../../core/themes/dimens.dart';
import '../view_models/booking_viewmodel.dart';

@ -1,7 +1,7 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:compass_model/model.dart';
import 'package:flutter/material.dart';
import '../../../domain/models/booking/booking.dart';
import '../../../utils/image_error_listener.dart';
import '../../core/localization/applocalization.dart';
import '../../core/themes/colors.dart';

@ -11,14 +11,17 @@ class AppLocalization {
static const _strings = <String, String>{
'activities': 'Activities',
'addDates': 'Add Dates',
'bookingDeleted': 'Booking deleted',
'bookNewTrip': 'Book New Trip',
'close': 'Close',
'confirm': 'Confirm',
'daytime': 'Daytime',
'errorWhileDeletingBooking': 'Error while deleting booking',
'errorWhileLoadingActivities': 'Error while loading activities',
'errorWhileLoadingBooking': 'Error while loading booking',
'errorWhileLoadingContinents': 'Error while loading continents',
'errorWhileLoadingDestinations': 'Error while loading destinations',
'errorWhileLoadingHome': 'Error while loading home',
'errorWhileLogin': 'Error while trying to login',
'errorWhileLogout': 'Error while trying to logout',
'errorWhileSavingActivities': 'Error while saving activities',
@ -26,12 +29,12 @@ class AppLocalization {
'errorWhileSharing': 'Error while sharing booking',
'evening': 'Evening',
'login': 'Login',
'nameTrips': '{name}\'s Trips',
'search': 'Search',
'searchDestination': 'Search destination',
'selected': '{1} selected',
'shareTrip': 'Share Trip',
'tryAgain': 'Try again',
'yourBookings': 'Your bookings:',
'yourChosenActivities': 'Your chosen activities',
'when': 'When',
};
@ -87,7 +90,13 @@ class AppLocalization {
String get bookNewTrip => _get('bookNewTrip');
String get yourBookings => _get('yourBookings');
String get errorWhileLoadingHome => _get('errorWhileLoadingHome');
String get bookingDeleted => _get('bookingDeleted');
String get errorWhileDeletingBooking => _get('errorWhileDeletingBooking');
String nameTrips(String name) => _get('nameTrips').replaceAll('{name}', name);
String selected(int value) =>
_get('selected').replaceAll('{1}', value.toString());

@ -32,6 +32,8 @@ sealed class Dimens {
> 600 => dimensDesktop,
_ => dimensMobile,
};
abstract final double profilePictureSize;
}
/// Mobile dimensions
@ -41,6 +43,9 @@ class DimensMobile extends Dimens {
@override
double paddingScreenVertical = Dimens.paddingVertical;
@override
double get profilePictureSize => 64.0;
}
/// Desktop/Web dimensions
@ -50,4 +55,7 @@ class DimensDesktop extends Dimens {
@override
double paddingScreenVertical = 64.0;
@override
double get profilePictureSize => 128.0;
}

@ -1,24 +0,0 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
// TODO: Maybe the text styles here should be moved to the respective widgets
class TextStyles {
// Note: original Figma file uses Nikkei Maru
// which is not available on GoogleFonts
// Note: Card title theme doesn't change based on light/dark mode
static final cardTitleStyle = GoogleFonts.rubik(
textStyle: const TextStyle(
fontWeight: FontWeight.w800,
fontSize: 15.0,
color: Colors.white,
letterSpacing: 1,
shadows: [
// Helps to read the text a bit better
Shadow(
blurRadius: 3.0,
color: Colors.black,
)
],
),
);
}

@ -1,6 +1,6 @@
import 'package:compass_model/model.dart';
import 'package:flutter/material.dart';
import '../../../domain/models/itinerary_config/itinerary_config.dart';
import '../localization/applocalization.dart';
import '../themes/colors.dart';
import '../themes/dimens.dart';

@ -4,25 +4,35 @@ import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import '../../../data/repositories/booking/booking_repository.dart';
import '../../../data/repositories/user/user_repository.dart';
import '../../../domain/models/booking/booking_summary.dart';
import '../../../domain/models/user/user.dart';
import '../../../utils/command.dart';
import '../../../utils/result.dart';
class HomeViewModel extends ChangeNotifier {
HomeViewModel({
required BookingRepository bookingRepository,
}) : _bookingRepository = bookingRepository {
required UserRepository userRepository,
}) : _bookingRepository = bookingRepository,
_userRepository = userRepository {
load = Command0(_load)..execute();
deleteBooking = Command1(_deleteBooking);
}
final BookingRepository _bookingRepository;
final UserRepository _userRepository;
final _log = Logger('HomeViewModel');
List<BookingSummary> _bookings = [];
User? _user;
late Command0 load;
late Command1<void, int> deleteBooking;
List<BookingSummary> get bookings => _bookings;
User? get user => _user;
Future<Result> _load() async {
try {
final result = await _bookingRepository.getBookingsList();
@ -32,8 +42,48 @@ class HomeViewModel extends ChangeNotifier {
_log.fine('Loaded bookings');
case Error<List<BookingSummary>>():
_log.warning('Failed to load bookings', result.error);
return result;
}
final userResult = await _userRepository.getUser();
switch (userResult) {
case Ok<User>():
_user = userResult.value;
_log.fine('Loaded user');
case Error<User>():
_log.warning('Failed to load user', userResult.error);
}
return result;
return userResult;
} finally {
notifyListeners();
}
}
Future<Result<void>> _deleteBooking(int id) async {
try {
final resultDelete = await _bookingRepository.delete(id);
switch (resultDelete) {
case Ok<void>():
_log.fine('Deleted booking $id');
case Error<void>():
_log.warning('Failed to delete booking $id', resultDelete.error);
return resultDelete;
}
// After deleting the booking, we need to reload the bookings list.
// BookingRepository is the source of truth for bookings.
final resultLoadBookings = await _bookingRepository.getBookingsList();
switch (resultLoadBookings) {
case Ok<List<BookingSummary>>():
_bookings = resultLoadBookings.value;
_log.fine('Loaded bookings');
case Error<List<BookingSummary>>():
_log.warning('Failed to load bookings', resultLoadBookings.error);
return resultLoadBookings;
}
return resultLoadBookings;
} finally {
notifyListeners();
}

@ -1,17 +1,16 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../../../domain/models/booking/booking_summary.dart';
import '../../../routing/routes.dart';
import '../../auth/logout/view_models/logout_viewmodel.dart';
import '../../auth/logout/widgets/logout_button.dart';
import '../../core/localization/applocalization.dart';
import '../../core/themes/dimens.dart';
import '../../core/ui/date_format_start_end.dart';
import '../../core/ui/error_indicator.dart';
import '../view_models/home_viewmodel.dart';
import 'home_title.dart';
class HomeScreen extends StatelessWidget {
class HomeScreen extends StatefulWidget {
const HomeScreen({
super.key,
required this.viewModel,
@ -19,6 +18,30 @@ class HomeScreen extends StatelessWidget {
final HomeViewModel viewModel;
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
@override
void initState() {
super.initState();
widget.viewModel.deleteBooking.addListener(_onResult);
}
@override
void didUpdateWidget(covariant HomeScreen oldWidget) {
super.didUpdateWidget(oldWidget);
oldWidget.viewModel.deleteBooking.removeListener(_onResult);
widget.viewModel.deleteBooking.addListener(_onResult);
}
@override
void dispose() {
widget.viewModel.deleteBooking.removeListener(_onResult);
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -34,49 +57,79 @@ class HomeScreen extends StatelessWidget {
top: true,
bottom: true,
child: ListenableBuilder(
listenable: viewModel,
builder: (context, _) {
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.symmetric(
vertical: Dimens.of(context).paddingScreenVertical,
horizontal: Dimens.of(context).paddingScreenHorizontal,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
AppLocalization.of(context).yourBookings,
style: Theme.of(context).textTheme.headlineMedium,
),
LogoutButton(
viewModel: LogoutViewModel(
authRepository: context.read(),
itineraryConfigRepository: context.read(),
),
),
],
listenable: widget.viewModel.load,
builder: (context, child) {
if (widget.viewModel.load.running) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (widget.viewModel.load.error) {
return ErrorIndicator(
title: AppLocalization.of(context).errorWhileLoadingHome,
label: AppLocalization.of(context).tryAgain,
onPressed: widget.viewModel.load.execute,
);
}
return child!;
},
child: ListenableBuilder(
listenable: widget.viewModel,
builder: (context, _) {
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.symmetric(
vertical: Dimens.of(context).paddingScreenVertical,
horizontal: Dimens.of(context).paddingScreenHorizontal,
),
child: HomeHeader(viewModel: widget.viewModel),
),
),
),
SliverList.builder(
itemCount: viewModel.bookings.length,
itemBuilder: (_, index) => _Booking(
key: ValueKey(index),
booking: viewModel.bookings[index],
onTap: () => context.push(
Routes.bookingWithId(viewModel.bookings[index].id)),
),
)
],
);
},
SliverList.builder(
itemCount: widget.viewModel.bookings.length,
itemBuilder: (_, index) => _Booking(
key: ValueKey(widget.viewModel.bookings[index].id),
booking: widget.viewModel.bookings[index],
onTap: () => context.push(Routes.bookingWithId(
widget.viewModel.bookings[index].id)),
onDismissed: (_) =>
widget.viewModel.deleteBooking.execute(
widget.viewModel.bookings[index].id,
),
),
)
],
);
},
),
),
),
);
}
void _onResult() {
if (widget.viewModel.deleteBooking.completed) {
widget.viewModel.deleteBooking.clearResult();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalization.of(context).bookingDeleted),
),
);
}
if (widget.viewModel.deleteBooking.error) {
widget.viewModel.deleteBooking.clearResult();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalization.of(context).errorWhileDeletingBooking),
),
);
}
}
}
class _Booking extends StatelessWidget {
@ -84,37 +137,44 @@ class _Booking extends StatelessWidget {
super.key,
required this.booking,
required this.onTap,
required this.onDismissed,
});
final BookingSummary booking;
final GestureTapCallback onTap;
final DismissDirectionCallback onDismissed;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: Dimens.of(context).paddingScreenHorizontal,
vertical: Dimens.paddingVertical,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
booking.name,
style: Theme.of(context).textTheme.titleLarge,
),
Text(
dateFormatStartEnd(
DateTimeRange(
start: booking.startDate,
end: booking.endDate,
return Dismissible(
key: ValueKey(booking.id),
direction: DismissDirection.endToStart,
onDismissed: onDismissed,
child: InkWell(
onTap: onTap,
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: Dimens.of(context).paddingScreenHorizontal,
vertical: Dimens.paddingVertical,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
booking.name,
style: Theme.of(context).textTheme.titleLarge,
),
Text(
dateFormatStartEnd(
DateTimeRange(
start: booking.startDate,
end: booking.endDate,
),
),
style: Theme.of(context).textTheme.bodyLarge,
),
style: Theme.of(context).textTheme.bodyLarge,
),
],
],
),
),
),
);

@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:provider/provider.dart';
import '../../auth/logout/view_models/logout_viewmodel.dart';
import '../../auth/logout/widgets/logout_button.dart';
import '../../core/localization/applocalization.dart';
import '../../core/themes/dimens.dart';
import '../view_models/home_viewmodel.dart';
class HomeHeader extends StatelessWidget {
const HomeHeader({
super.key,
required this.viewModel,
});
final HomeViewModel viewModel;
@override
Widget build(BuildContext context) {
final user = viewModel.user;
if (user == null) {
return const SizedBox();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipOval(
child: Image.asset(
user.picture,
width: Dimens.of(context).profilePictureSize,
height: Dimens.of(context).profilePictureSize,
),
),
LogoutButton(
viewModel: LogoutViewModel(
authRepository: context.read(),
itineraryConfigRepository: context.read(),
),
),
],
),
const SizedBox(height: Dimens.paddingVertical),
_Title(
text: AppLocalization.of(context).nameTrips(user.name),
),
],
);
}
}
class _Title extends StatelessWidget {
const _Title({
required this.text,
});
final String text;
@override
Widget build(BuildContext context) {
return ShaderMask(
blendMode: BlendMode.srcIn,
shaderCallback: (bounds) => RadialGradient(
center: Alignment.bottomLeft,
radius: 2,
colors: [
Colors.purple.shade700,
Colors.purple.shade400,
],
).createShader(
Rect.fromLTWH(0, 0, bounds.width, bounds.height),
),
child: Text(
text,
style: GoogleFonts.rubik(
textStyle: Theme.of(context).textTheme.headlineLarge,
),
),
);
}
}

@ -1,8 +1,9 @@
import 'package:compass_model/model.dart';
import 'package:logging/logging.dart';
import '../../../data/repositories/destination/destination_repository.dart';
import '../../../data/repositories/itinerary_config/itinerary_config_repository.dart';
import '../../../domain/models/destination/destination.dart';
import '../../../domain/models/itinerary_config/itinerary_config.dart';
import '../../../utils/command.dart';
import '../../../utils/result.dart';
import 'package:flutter/cupertino.dart';

@ -1,9 +1,8 @@
import 'package:compass_model/model.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../domain/models/destination/destination.dart';
import '../../../utils/image_error_listener.dart';
import '../../core/themes/text_styles.dart';
import '../../core/ui/tag_chip.dart';
class ResultCard extends StatelessWidget {
@ -39,7 +38,7 @@ class ResultCard extends StatelessWidget {
children: [
Text(
destination.name.toUpperCase(),
style: TextStyles.cardTitleStyle,
style: _cardTitleStyle,
),
const SizedBox(
height: 6,
@ -68,3 +67,19 @@ class ResultCard extends StatelessWidget {
);
}
}
final _cardTitleStyle = GoogleFonts.rubik(
textStyle: const TextStyle(
fontWeight: FontWeight.w800,
fontSize: 15.0,
color: Colors.white,
letterSpacing: 1,
shadows: [
// Helps to read the text a bit better
Shadow(
blurRadius: 3.0,
color: Colors.black,
)
],
),
);

@ -1,9 +1,10 @@
import 'package:flutter/material.dart';
import 'package:compass_model/model.dart';
import 'package:logging/logging.dart';
import '../../../data/repositories/continent/continent_repository.dart';
import '../../../data/repositories/itinerary_config/itinerary_config_repository.dart';
import '../../../domain/models/continent/continent.dart';
import '../../../domain/models/itinerary_config/itinerary_config.dart';
import '../../../utils/command.dart';
import '../../../utils/result.dart';

@ -1,8 +1,8 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:compass_model/model.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../domain/models/continent/continent.dart';
import '../../../utils/image_error_listener.dart';
import '../../core/localization/applocalization.dart';
import '../../core/themes/colors.dart';

@ -9,8 +9,6 @@ environment:
dependencies:
cached_network_image: ^3.3.1
collection: ^1.18.0
compass_model:
path: ../model
flutter:
sdk: flutter
flutter_localizations:
@ -44,3 +42,4 @@ flutter:
- assets/activities.json
- assets/destinations.json
- assets/logo.svg
- assets/user.jpg

@ -21,7 +21,7 @@ void main() {
test('should get booking', () async {
final result = await bookingRepository.getBooking(0);
final booking = result.asOk.value;
expect(booking, kBooking);
expect(booking, kBooking.copyWith(id: 0));
});
test('should create booking', () async {
@ -36,5 +36,21 @@ void main() {
final list = result.asOk.value;
expect(list, [kBookingSummary]);
});
test('should delete booking', () async {
// Ensure no bookings exist
expect(fakeApiClient.bookings, isEmpty);
// Add a booking
var result = await bookingRepository.createBooking(kBooking);
expect(result, isA<Ok<void>>());
// Delete the booking
result = await bookingRepository.delete(0);
expect(result, isA<Ok<void>>());
// Check if the booking was deleted from the server
expect(fakeApiClient.bookings, isEmpty);
});
});
}

@ -0,0 +1,79 @@
import 'package:compass_app/data/services/api/api_client.dart';
import 'package:compass_app/domain/models/continent/continent.dart';
import 'package:compass_app/utils/result.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../../../testing/mocks.dart';
import '../../../../testing/models/activity.dart';
import '../../../../testing/models/booking.dart';
import '../../../../testing/models/destination.dart';
import '../../../../testing/models/user.dart';
void main() {
group('ApiClient', () {
late MockHttpClient mockHttpClient;
late ApiClient apiClient;
setUp(() {
mockHttpClient = MockHttpClient();
apiClient = ApiClient(clientFactory: () => mockHttpClient);
});
test('should get continents', () async {
final continents = [const Continent(name: 'NAME', imageUrl: 'URL')];
mockHttpClient.mockGet('/continent', continents);
final result = await apiClient.getContinents();
expect(result.asOk.value, continents);
});
test('should get activities by destination', () async {
final activites = [kActivity];
mockHttpClient.mockGet(
'/destination/${kDestination1.ref}/activity',
activites,
);
final result =
await apiClient.getActivityByDestination(kDestination1.ref);
expect(result.asOk.value, activites);
});
test('should get booking', () async {
mockHttpClient.mockGet(
'/booking/${kBookingApiModel.id}',
kBookingApiModel,
);
final result = await apiClient.getBooking(kBookingApiModel.id!);
expect(result.asOk.value, kBookingApiModel);
});
test('should get bookings', () async {
mockHttpClient.mockGet('/booking', [kBookingApiModel]);
final result = await apiClient.getBookings();
expect(result.asOk.value, [kBookingApiModel]);
});
test('should get destinations', () async {
mockHttpClient.mockGet('/destination', [kDestination1]);
final result = await apiClient.getDestinations();
expect(result.asOk.value, [kDestination1]);
});
test('should get user', () async {
mockHttpClient.mockGet('/user', userApiModel);
final result = await apiClient.getUser();
expect(result.asOk.value, userApiModel);
});
test('should post booking', () async {
mockHttpClient.mockPost('/booking', kBookingApiModel);
final result = await apiClient.postBooking(kBookingApiModel);
expect(result.asOk.value, kBookingApiModel);
});
test('should delete booking', () async {
mockHttpClient.mockDelete('/booking/0');
final result = await apiClient.deleteBooking(0);
expect(result, isA<Ok<void>>());
});
});
}

@ -0,0 +1,37 @@
import 'package:compass_app/data/services/api/auth_api_client.dart';
import 'package:compass_app/data/services/api/model/login_request/login_request.dart';
import 'package:compass_app/data/services/api/model/login_response/login_response.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../../../testing/mocks.dart';
void main() {
group('AuthApiClient', () {
late MockHttpClient mockHttpClient;
late AuthApiClient apiClient;
setUp(() {
mockHttpClient = MockHttpClient();
apiClient = AuthApiClient(clientFactory: () => mockHttpClient);
});
test('should post login', () async {
const loginResponse = LoginResponse(
token: 'TOKEN',
userId: '123',
);
mockHttpClient.mockPost(
'/login',
loginResponse,
200,
);
final result = await apiClient.login(
const LoginRequest(
email: 'EMAIL',
password: 'PASSWORD',
),
);
expect(result.asOk.value, loginResponse);
});
});
}

@ -1,5 +1,5 @@
import 'package:compass_app/domain/components/booking/booking_create_component.dart';
import 'package:compass_model/model.dart';
import 'package:compass_app/domain/models/itinerary_config/itinerary_config.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../../../testing/fakes/repositories/fake_activities_repository.dart';

@ -1,5 +1,5 @@
import 'package:compass_app/domain/components/booking/booking_share_component.dart';
import 'package:compass_model/model.dart';
import 'package:compass_app/domain/models/booking/booking.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../../../testing/models/activity.dart';

@ -1,7 +1,7 @@
import 'package:compass_app/domain/models/itinerary_config/itinerary_config.dart';
import 'package:compass_app/ui/activities/view_models/activities_viewmodel.dart';
import 'package:compass_app/ui/activities/widgets/activities_screen.dart';
import 'package:compass_app/ui/activities/widgets/activity_entry.dart';
import 'package:compass_model/model.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';

@ -1,6 +1,6 @@
import 'package:compass_app/domain/models/itinerary_config/itinerary_config.dart';
import 'package:compass_app/ui/auth/logout/view_models/logout_viewmodel.dart';
import 'package:compass_app/ui/auth/logout/widgets/logout_button.dart';
import 'package:compass_model/model.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail_image_network/mocktail_image_network.dart';

@ -1,8 +1,8 @@
import 'package:compass_app/domain/components/booking/booking_create_component.dart';
import 'package:compass_app/domain/components/booking/booking_share_component.dart';
import 'package:compass_app/domain/models/itinerary_config/itinerary_config.dart';
import 'package:compass_app/ui/booking/view_models/booking_viewmodel.dart';
import 'package:compass_app/ui/booking/widgets/booking_screen.dart';
import 'package:compass_model/model.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';

@ -12,6 +12,7 @@ import '../../../../testing/app.dart';
import '../../../../testing/fakes/repositories/fake_auth_repository.dart';
import '../../../../testing/fakes/repositories/fake_booking_repository.dart';
import '../../../../testing/fakes/repositories/fake_itinerary_config_repository.dart';
import '../../../../testing/fakes/repositories/fake_user_repository.dart';
import '../../../../testing/mocks.dart';
import '../../../../testing/models/booking.dart';
@ -19,10 +20,13 @@ void main() {
group('HomeScreen tests', () {
late HomeViewModel viewModel;
late MockGoRouter goRouter;
late FakeBookingRepository bookingRepository;
setUp(() {
bookingRepository = FakeBookingRepository()..createBooking(kBooking);
viewModel = HomeViewModel(
bookingRepository: FakeBookingRepository()..createBooking(kBooking),
bookingRepository: bookingRepository,
userRepository: FakeUserRepository(),
);
goRouter = MockGoRouter();
when(() => goRouter.push(any())).thenAnswer((_) => Future.value(null));
@ -49,6 +53,13 @@ void main() {
expect(find.byType(HomeScreen), findsOneWidget);
});
testWidgets('should show user name', (tester) async {
await loadWidget(tester);
await tester.pumpAndSettle();
expect(find.text('NAME\'s Trips'), findsOneWidget);
});
testWidgets('should navigate to search', (tester) async {
await loadWidget(tester);
await tester.pumpAndSettle();
@ -72,5 +83,20 @@ void main() {
// Should navigate to results screen
verify(() => goRouter.push(Routes.bookingWithId(0))).called(1);
});
testWidgets('should delete booking', (tester) async {
await loadWidget(tester);
await tester.pumpAndSettle();
// Swipe on booking (created from kBooking)
await tester.drag(find.text('name1, Europe'), const Offset(-1000, 0));
await tester.pumpAndSettle();
// Existing booking should be gone
expect(find.text('name1, Europe'), findsNothing);
// Booking should be deleted from repository
expect(bookingRepository.bookings, isEmpty);
});
});
}

@ -1,6 +1,6 @@
import 'package:compass_app/domain/models/itinerary_config/itinerary_config.dart';
import 'package:compass_app/ui/results/view_models/results_viewmodel.dart';
import 'package:compass_app/ui/results/widgets/results_screen.dart';
import 'package:compass_model/model.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:mocktail_image_network/mocktail_image_network.dart';

@ -1,5 +1,5 @@
import 'package:compass_app/domain/models/itinerary_config/itinerary_config.dart';
import 'package:compass_app/ui/results/view_models/results_viewmodel.dart';
import 'package:compass_model/model.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../../testing/fakes/repositories/fake_destination_repository.dart';
@ -21,7 +21,6 @@ void main() {
// perform a simple test
// verifies that the list of items is properly loaded
// TODO: Verify loading state and calls to search method
test('should load items', () async {
expect(viewModel.destinations.length, 2);
});

@ -1,6 +1,6 @@
import 'package:compass_app/data/repositories/activity/activity_repository.dart';
import 'package:compass_app/domain/models/activity/activity.dart';
import 'package:compass_app/utils/result.dart';
import 'package:compass_model/src/model/activity/activity.dart';
import 'package:flutter/foundation.dart';
import '../../models/activity.dart';

@ -1,15 +1,16 @@
import 'package:collection/collection.dart';
import 'package:compass_app/data/repositories/booking/booking_repository.dart';
import 'package:compass_app/domain/models/booking/booking.dart';
import 'package:compass_app/domain/models/booking/booking_summary.dart';
import 'package:compass_app/utils/result.dart';
import 'package:compass_model/src/model/booking/booking.dart';
class FakeBookingRepository implements BookingRepository {
List<Booking> bookings = List.empty(growable: true);
int sequentialId = 0;
@override
Future<Result<void>> createBooking(Booking booking) async {
bookings.add(booking);
final bookingWithId = booking.copyWith(id: sequentialId++);
bookings.add(bookingWithId);
return Result.ok(null);
}
@ -25,9 +26,9 @@ class FakeBookingRepository implements BookingRepository {
List<BookingSummary> _createSummaries() {
return bookings
.mapIndexed(
(index, booking) => BookingSummary(
id: index,
.map(
(booking) => BookingSummary(
id: booking.id!,
name:
'${booking.destination.name}, ${booking.destination.continent}',
startDate: booking.startDate,
@ -36,4 +37,10 @@ class FakeBookingRepository implements BookingRepository {
)
.toList();
}
@override
Future<Result<void>> delete(int id) async {
bookings.removeWhere((booking) => booking.id == id);
return Result.ok(null);
}
}

@ -1,5 +1,5 @@
import 'package:compass_model/model.dart';
import 'package:compass_app/data/repositories/continent/continent_repository.dart';
import 'package:compass_app/domain/models/continent/continent.dart';
import 'package:compass_app/utils/result.dart';
import 'package:flutter/foundation.dart';

@ -1,5 +1,5 @@
import 'package:compass_model/model.dart';
import 'package:compass_app/data/repositories/destination/destination_repository.dart';
import 'package:compass_app/domain/models/destination/destination.dart';
import 'package:compass_app/utils/result.dart';
import 'package:flutter/foundation.dart';

@ -1,6 +1,6 @@
import 'package:compass_app/data/repositories/itinerary_config/itinerary_config_repository.dart';
import 'package:compass_app/domain/models/itinerary_config/itinerary_config.dart';
import 'package:compass_app/utils/result.dart';
import 'package:compass_model/src/model/itinerary_config/itinerary_config.dart';
import 'package:flutter/foundation.dart';
class FakeItineraryConfigRepository implements ItineraryConfigRepository {

@ -0,0 +1,12 @@
import 'package:compass_app/data/repositories/user/user_repository.dart';
import 'package:compass_app/domain/models/user/user.dart';
import 'package:compass_app/utils/result.dart';
import '../../models/user.dart';
class FakeUserRepository implements UserRepository {
@override
Future<Result<User>> getUser() async {
return Result.ok(user);
}
}

@ -1,10 +1,14 @@
import 'package:compass_app/data/services/api/api_client.dart';
import 'package:compass_app/data/services/api/model/booking/booking_api_model.dart';
import 'package:compass_app/data/services/api/model/user/user_api_model.dart';
import 'package:compass_app/domain/models/activity/activity.dart';
import 'package:compass_app/domain/models/continent/continent.dart';
import 'package:compass_app/domain/models/destination/destination.dart';
import 'package:compass_app/utils/result.dart';
import 'package:compass_model/model.dart';
import '../../models/activity.dart';
import '../../models/booking.dart';
import '../../models/user.dart';
class FakeApiClient implements ApiClient {
// Should not increase when using cached data
@ -98,4 +102,15 @@ class FakeApiClient implements ApiClient {
bookings.add(bookingWithId);
return Result.ok(bookingWithId);
}
@override
Future<Result<UserApiModel>> getUser() async {
return Result.ok(userApiModel);
}
@override
Future<Result<void>> deleteBooking(int id) async {
bookings.removeWhere((booking) => booking.id == id);
return Result.ok(null);
}
}

@ -1,7 +1,7 @@
import 'package:compass_app/data/services/api/auth_api_client.dart';
import 'package:compass_app/data/services/api/model/login_request/login_request.dart';
import 'package:compass_app/data/services/api/model/login_response/login_response.dart';
import 'package:compass_app/utils/result.dart';
import 'package:compass_model/src/model/auth/login_request/login_request.dart';
import 'package:compass_model/src/model/auth/login_response/login_response.dart';
class FakeAuthApiClient implements AuthApiClient {
@override

@ -1,4 +1,54 @@
import 'dart:convert';
import 'dart:io';
import 'package:go_router/go_router.dart';
import 'package:mocktail/mocktail.dart';
class MockGoRouter extends Mock implements GoRouter {}
class MockHttpClient extends Mock implements HttpClient {}
class MockHttpHeaders extends Mock implements HttpHeaders {}
class MockHttpClientRequest extends Mock implements HttpClientRequest {}
class MockHttpClientResponse extends Mock implements HttpClientResponse {}
extension HttpMethodMocks on MockHttpClient {
void mockGet(String path, Object object) {
when(() => get(any(), any(), path)).thenAnswer((invocation) {
final request = MockHttpClientRequest();
final response = MockHttpClientResponse();
when(() => request.close()).thenAnswer((_) => Future.value(response));
when(() => request.headers).thenReturn(MockHttpHeaders());
when(() => response.statusCode).thenReturn(200);
when(() => response.transform(utf8.decoder))
.thenAnswer((_) => Stream.value(jsonEncode(object)));
return Future.value(request);
});
}
void mockPost(String path, Object object, [int statusCode = 201]) {
when(() => post(any(), any(), path)).thenAnswer((invocation) {
final request = MockHttpClientRequest();
final response = MockHttpClientResponse();
when(() => request.close()).thenAnswer((_) => Future.value(response));
when(() => request.headers).thenReturn(MockHttpHeaders());
when(() => response.statusCode).thenReturn(statusCode);
when(() => response.transform(utf8.decoder))
.thenAnswer((_) => Stream.value(jsonEncode(object)));
return Future.value(request);
});
}
void mockDelete(String path) {
when(() => delete(any(), any(), path)).thenAnswer((invocation) {
final request = MockHttpClientRequest();
final response = MockHttpClientResponse();
when(() => request.close()).thenAnswer((_) => Future.value(response));
when(() => request.headers).thenReturn(MockHttpHeaders());
when(() => response.statusCode).thenReturn(204);
return Future.value(request);
});
}
}

@ -1,4 +1,4 @@
import 'package:compass_model/model.dart';
import 'package:compass_app/domain/models/activity/activity.dart';
const kActivity = Activity(
description: 'DESCRIPTION',

@ -1,6 +1,6 @@
import 'package:compass_app/data/services/api/model/booking/booking_api_model.dart';
import 'package:compass_app/domain/models/booking/booking.dart';
import 'package:compass_app/domain/models/booking/booking_summary.dart';
import 'package:compass_model/model.dart';
import 'activity.dart';
import 'destination.dart';

@ -1,4 +1,4 @@
import 'package:compass_model/model.dart';
import 'package:compass_app/domain/models/destination/destination.dart';
const kDestination1 = Destination(
ref: 'ref1',

@ -0,0 +1,14 @@
import 'package:compass_app/data/services/api/model/user/user_api_model.dart';
import 'package:compass_app/domain/models/user/user.dart';
const userApiModel = UserApiModel(
id: 'ID',
name: 'NAME',
email: 'EMAIL',
picture: 'assets/user.jpg',
);
const user = User(
name: 'NAME',
picture: 'assets/user.jpg',
);

@ -1,7 +0,0 @@
# https://dart.dev/guides/libraries/private-files
# Created by `dart pub`
.dart_tool/
# Avoid committing pubspec.lock for library packages; see
# https://dart.dev/guides/libraries/private-files#pubspeclock.
pubspec.lock

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save