Skip to content

Compass App: Add "Activities", "Itinerary Config" and MVVM Commands #2366

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 24 commits into from
Jul 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32,678 changes: 32,678 additions & 0 deletions compass_app/app/assets/activities.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions compass_app/app/ios/Flutter/Debug.xcconfig
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
1 change: 1 addition & 0 deletions compass_app/app/ios/Flutter/Release.xcconfig
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
44 changes: 44 additions & 0 deletions compass_app/app/ios/Podfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '12.0'

# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}

def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end

File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end

require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)

flutter_ios_podfile_setup

target 'Runner' do
use_frameworks!
use_modular_headers!

flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end

post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end
19 changes: 19 additions & 0 deletions compass_app/app/lib/config/dependencies.dart
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import 'package:provider/single_child_widget.dart';
import 'package:provider/provider.dart';

import '../data/repositories/activity/activity_repository.dart';
import '../data/repositories/activity/activity_repository_local.dart';
import '../data/repositories/activity/activity_repository_remote.dart';
import '../data/repositories/continent/continent_repository.dart';
import '../data/repositories/continent/continent_repository_local.dart';
import '../data/repositories/continent/continent_repository_remote.dart';
import '../data/repositories/destination/destination_repository.dart';
import '../data/repositories/destination/destination_repository_local.dart';
import '../data/repositories/destination/destination_repository_remote.dart';
import '../data/repositories/itinerary_config/itinerary_config_repository.dart';
import '../data/repositories/itinerary_config/itinerary_config_repository_memory.dart';
import '../data/services/api_client.dart';

/// Configure dependencies for remote data.
Expand All @@ -25,6 +30,14 @@ List<SingleChildWidget> get providersRemote {
apiClient: apiClient,
) as ContinentRepository,
),
Provider.value(
value: ActivityRepositoryRemote(
apiClient: apiClient,
) as ActivityRepository,
),
Provider.value(
value: ItineraryConfigRepositoryMemory() as ItineraryConfigRepository,
),
];
}

Expand All @@ -38,5 +51,11 @@ List<SingleChildWidget> get providersLocal {
Provider.value(
value: ContinentRepositoryLocal() as ContinentRepository,
),
Provider.value(
value: ActivityRepositoryLocal() as ActivityRepository,
),
Provider.value(
value: ItineraryConfigRepositoryMemory() as ItineraryConfigRepository,
),
];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import 'package:compass_model/model.dart';

import '../../../utils/result.dart';

/// Data source for activities.
abstract class ActivityRepository {
/// Get activities by [Destination] ref.
Future<Result<List<Activity>>> getByDestination(String ref);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import 'dart:convert';

import 'package:compass_model/model.dart';
import 'package:flutter/services.dart';

import '../../../utils/result.dart';
import 'activity_repository.dart';

/// Local implementation of ActivityRepository
/// Uses data from assets folder
class ActivityRepositoryLocal implements ActivityRepository {
@override
Future<Result<List<Activity>>> getByDestination(String ref) async {
try {
final localData = await _loadAsset();
final list = _parse(localData);

final activities =
list.where((activity) => activity.destinationRef == ref).toList();

return Result.ok(activities);
} on Exception catch (error) {
return Result.error(error);
}
}

Future<String> _loadAsset() async {
return await rootBundle.loadString('assets/activities.json');
}

List<Activity> _parse(String localData) {
final parsed = (jsonDecode(localData) as List).cast<Map<String, dynamic>>();

return parsed.map<Activity>((json) => Activity.fromJson(json)).toList();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import 'package:compass_model/model.dart';

import '../../../utils/result.dart';
import '../../services/api_client.dart';
import 'activity_repository.dart';

/// Remote data source for [Activity].
/// Implements basic local caching.
/// See: https://docs.flutter.dev/get-started/fwe/local-caching
class ActivityRepositoryRemote implements ActivityRepository {
ActivityRepositoryRemote({
required ApiClient apiClient,
}) : _apiClient = apiClient;

final ApiClient _apiClient;

final Map<String, List<Activity>> _cachedData = {};

@override
Future<Result<List<Activity>>> getByDestination(String ref) async {
if (!_cachedData.containsKey(ref)) {
// No cached data, request activities
final result = await _apiClient.getActivityByDestination(ref);
if (result is Ok) {
_cachedData[ref] = result.asOk.value;
}
return result;
} else {
// Return cached data if available
return Result.ok(_cachedData[ref]!);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ import '../../../utils/result.dart';
abstract class DestinationRepository {
/// Get complete list of destinations
Future<Result<List<Destination>>> getDestinations();

// TODO: Consider creating getByContinent instead of filtering in ViewModel
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import 'package:compass_model/model.dart';

import '../../../utils/result.dart';

/// Data source for the current [ItineraryConfig]
abstract class ItineraryConfigRepository {
/// Get current [ItineraryConfig], may be empty if no configuration started.
/// Method is async to support writing to database, file, etc.
Future<Result<ItineraryConfig>> getItineraryConfig();

/// Sets [ItineraryConfig], overrides the previous one stored.
/// Returns Result.Ok if set is successful.
Future<Result<void>> setItineraryConfig(ItineraryConfig itineraryConfig);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import 'dart:async';

import 'package:compass_model/model.dart';

import '../../../utils/result.dart';
import 'itinerary_config_repository.dart';

/// In-memory implementation of [ItineraryConfigRepository].
class ItineraryConfigRepositoryMemory implements ItineraryConfigRepository {
ItineraryConfig? _itineraryConfig;

@override
Future<Result<ItineraryConfig>> getItineraryConfig() async {
return Result.ok(_itineraryConfig ?? const ItineraryConfig());
}

@override
Future<Result<bool>> setItineraryConfig(
ItineraryConfig itineraryConfig,
) async {
_itineraryConfig = itineraryConfig;
return Result.ok(true);
}
}
22 changes: 22 additions & 0 deletions compass_app/app/lib/data/services/api_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,26 @@ class ApiClient {
client.close();
}
}

Future<Result<List<Activity>>> getActivityByDestination(String ref) async {
final client = HttpClient();
try {
final request =
await client.get('localhost', 8080, '/destination/$ref/activity');
final response = await request.close();
if (response.statusCode == 200) {
final stringData = await response.transform(utf8.decoder).join();
final json = jsonDecode(stringData) as List<dynamic>;
final activities =
json.map((element) => Activity.fromJson(element)).toList();
return Result.ok(activities);
} else {
return Result.error(const HttpException("Invalid response"));
}
} on Exception catch (error) {
return Result.error(error);
} finally {
client.close();
}
}
}
29 changes: 23 additions & 6 deletions compass_app/app/lib/routing/router.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';

import '../ui/activities/view_models/activities_viewmodel.dart';
import '../ui/activities/widgets/activities_screen.dart';
import '../ui/results/view_models/results_viewmodel.dart';
import '../ui/results/widgets/results_screen.dart';
import '../ui/search_form/view_models/search_form_viewmodel.dart';
Expand All @@ -9,12 +11,15 @@ import '../ui/search_form/widgets/search_form_screen.dart';
/// Top go_router entry point
final router = GoRouter(
initialLocation: '/',
debugLogDiagnostics: true,
routes: [
GoRoute(
path: '/',
builder: (context, state) {
final viewModel =
SearchFormViewModel(continentRepository: context.read());
final viewModel = SearchFormViewModel(
continentRepository: context.read(),
itineraryConfigRepository: context.read(),
);
return SearchFormScreen(viewModel: viewModel);
},
routes: [
Expand All @@ -23,11 +28,23 @@ final router = GoRouter(
builder: (context, state) {
final viewModel = ResultsViewModel(
destinationRepository: context.read(),
itineraryConfigRepository: context.read(),
);
return ResultsScreen(
viewModel: viewModel,
);
},
),
GoRoute(
path: 'activities',
builder: (context, state) {
final viewModel = ActivitiesViewModel(
activityRepository: context.read(),
itineraryConfigRepository: context.read(),
);
return ActivitiesScreen(
viewModel: viewModel,
);
final parameters = state.uri.queryParameters;
// TODO: Pass the rest of query parameters to the ViewModel
viewModel.search(continent: parameters['continent']);
return ResultsScreen(viewModel: viewModel);
},
),
],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import 'package:compass_model/model.dart';
import 'package:flutter/foundation.dart';

import '../../../data/repositories/activity/activity_repository.dart';
import '../../../data/repositories/itinerary_config/itinerary_config_repository.dart';
import '../../../utils/command.dart';
import '../../../utils/result.dart';

class ActivitiesViewModel extends ChangeNotifier {
ActivitiesViewModel({
required ActivityRepository activityRepository,
required ItineraryConfigRepository itineraryConfigRepository,
}) : _activityRepository = activityRepository,
_itineraryConfigRepository = itineraryConfigRepository {
loadActivities = Command0(_loadActivities)..execute();
}

final ActivityRepository _activityRepository;
final ItineraryConfigRepository _itineraryConfigRepository;
List<Activity> _activities = <Activity>[];
final Set<String> _selectedActivities = <String>{};

/// List of [Activity] per destination.
List<Activity> get activities => _activities;

/// Selected [Activity] by ref.
Set<String> get selectedActivities => _selectedActivities;

/// Load list of [Activity] for a [Destination] by ref.
late final Command0 loadActivities;

Future<void> _loadActivities() async {
final result = await _itineraryConfigRepository.getItineraryConfig();
if (result is Error) {
// TODO: Handle error
print(result.asError.error);
return;
}

final destinationRef = result.asOk.value.destination;
if (destinationRef == null) {
// TODO: Error here
return;
}

final resultActivities =
await _activityRepository.getByDestination(destinationRef);
switch (resultActivities) {
case Ok():
{
_activities = resultActivities.value;
print(_activities);
}
case Error():
{
// TODO: Handle error
print(resultActivities.error);
}
}
notifyListeners();
}

/// Add [Activity] to selected list.
void addActivity(String activityRef) {
assert(
activities.any((activity) => activity.ref == activityRef),
"Activity $activityRef not found",
);
_selectedActivities.add(activityRef);
notifyListeners();
}

/// Remove [Activity] from selected list.
void removeActivity(String activityRef) {
assert(
activities.any((activity) => activity.ref == activityRef),
"Activity $activityRef not found",
);
_selectedActivities.remove(activityRef);
notifyListeners();
}
}
Loading