Compare commits
4 Commits
v0.1.5
...
feature/fr
| Author | SHA1 | Date | |
|---|---|---|---|
| 014b48591e | |||
| 81ed2fd8c3 | |||
| 239b63ca81 | |||
| 0070e57aec |
@@ -37,8 +37,8 @@ jobs:
|
||||
|
||||
- uses: https://github.com/actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'zulu'
|
||||
java-version: '21'
|
||||
distribution: 'oracle'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: https://github.com/android-actions/setup-android@v3
|
||||
|
||||
5
.vscode/launch.json
vendored
5
.vscode/launch.json
vendored
@@ -33,8 +33,11 @@
|
||||
"request": "launch",
|
||||
"program": "lib/main.dart",
|
||||
"cwd": "${workspaceFolder}/frontend",
|
||||
"args": [
|
||||
"--dart-define-from-file=secrets.json"
|
||||
],
|
||||
"env": {
|
||||
"GOOGLE_MAPS_API_KEY": "testing"
|
||||
"ANDROID_GOOGLE_MAPS_API_KEY": "AIzaSyD6RK_pzKFc8T-t1t0jiC3PNRZwNXECFG4"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
19
default.nix
19
default.nix
@@ -1,17 +1,20 @@
|
||||
{ pkgs ? import <nixpkgs> { config.android_sdk.accept_license = true; config.allowUnfree = true; } }:
|
||||
|
||||
pkgs.mkShell {
|
||||
buildInputs = [
|
||||
pkgs.flutter
|
||||
#pkgs.android-tools # for adb
|
||||
#pkgs.openjdk # required for Android builds
|
||||
|
||||
buildInputs = with pkgs; [
|
||||
flutter
|
||||
android-tools # for adb
|
||||
openjdk # required for Android builds
|
||||
# pkgs.androidenv.androidPkgs.androidsdk # The customized SDK that we've made above
|
||||
# androidenv.androidPkgs.ndk-bundle
|
||||
];
|
||||
|
||||
# Set up Android SDK paths if needed
|
||||
# Setting up android build environments on nix is a bit of a pain - immutable paths, etc.
|
||||
# I used a hacky workaround by manually downloading the SDK and NDK from the website and simply telling the shell where to find them
|
||||
ANDROID_HOME = "/scratch/remy/android";
|
||||
|
||||
shellHook = ''
|
||||
export ANDROID_SDK_ROOT=${pkgs.androidsdk}/libexec/android-sdk
|
||||
export PATH=$PATH:${pkgs.androidsdk}/libexec/android-sdk/platform-tools
|
||||
echo "Flutter dev environment ready. 'adb' and 'flutter' are available."
|
||||
'';
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ To truly deploy a new version of the application, i.e. to the official app store
|
||||
git tag -a v<name> -m "Release <name>"
|
||||
git push origin v<name>
|
||||
```
|
||||
We adhere to the [Semantic Versioning](https://semver.org/) standard, so the tag should be of the form `v0.1.8` for example.
|
||||
We adhere to the [Semantic Versioning](https://semver.org/) standard, so the tag should be of the form `v0.1.8` for example.
|
||||
|
||||
### Icons and logos
|
||||
The application uses a custom launcher icon and splash screen. These are managed platform-independently using the `flutter_launcher_icons` package.
|
||||
@@ -66,3 +66,10 @@ These are used by the CI/CD pipeline to deploy the application.
|
||||
- `IOS_ASC_KEY_ID` as well
|
||||
- `IOS_MATCH_PASSWORD` is used by fastlane match to download the certificates
|
||||
- `IOS_MATCH_REPO_SSH_KEY_BASE64` is used to authenticate with the git repository where the certificates are stored
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## Android SDK&NDK setup
|
||||
|
||||
@@ -24,5 +24,15 @@ linter:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# Exclude legacy/experimental code that is not part of the current
|
||||
# refactor work. This prevents the analyzer from failing on old files
|
||||
# in `lib/old` while we iterate on the new architecture in `lib/`.
|
||||
analyzer:
|
||||
exclude:
|
||||
- lib/old/**
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
|
||||
formatter:
|
||||
page_width: 200
|
||||
|
||||
@@ -49,7 +49,8 @@ if (secretPropertiesFile.exists()) {
|
||||
android {
|
||||
namespace "com.anydev.anyway"
|
||||
compileSdk flutter.compileSdkVersion
|
||||
ndkVersion flutter.ndkVersion
|
||||
ndkVersion = "27.0.12077973"
|
||||
// TODO - set back to ndkVersion flutter.ndkVersion once https://github.com/flutter/flutter/issues/139427 is resolved
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
@@ -65,7 +66,7 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
|
||||
|
||||
applicationId "com.anydev.anyway"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
|
||||
|
||||
@@ -19,7 +19,7 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version "8.1.0" apply false
|
||||
id "com.android.application" version "8.7.0" apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.0.20" apply false
|
||||
}
|
||||
|
||||
|
||||
83
frontend/lib/core/constants.dart
Normal file
83
frontend/lib/core/constants.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const String APP_NAME = 'AnyWay';
|
||||
|
||||
String API_URL_BASE = 'https://anyway.anydev.info';
|
||||
String API_URL_DEBUG = 'https://anyway-stg.anydev.info';
|
||||
String PRIVACY_URL = 'https://anydev.info/privacy';
|
||||
|
||||
const String MAP_ID = '41c21ac9b81dbfd8';
|
||||
|
||||
|
||||
const Color GRADIENT_START = Color(0xFFF9B208);
|
||||
const Color GRADIENT_END = Color(0xFFE72E77);
|
||||
|
||||
const Color PRIMARY_COLOR = Color(0xFFF38F1A);
|
||||
|
||||
|
||||
|
||||
const double TRIP_PANEL_MAX_HEIGHT = 0.8;
|
||||
const double TRIP_PANEL_MIN_HEIGHT = 0.12;
|
||||
|
||||
ThemeData APP_THEME = ThemeData(
|
||||
primaryColor: PRIMARY_COLOR,
|
||||
|
||||
scaffoldBackgroundColor: Colors.white,
|
||||
cardColor: Colors.white,
|
||||
useMaterial3: true,
|
||||
|
||||
colorScheme: const ColorScheme.light(
|
||||
primary: PRIMARY_COLOR,
|
||||
secondary: GRADIENT_END,
|
||||
surface: Colors.white,
|
||||
error: Colors.red,
|
||||
onPrimary: Colors.white,
|
||||
onSecondary: Color.fromARGB(255, 30, 22, 22),
|
||||
onSurface: Colors.black,
|
||||
onError: Colors.white,
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
|
||||
|
||||
textButtonTheme: const TextButtonThemeData(
|
||||
style: ButtonStyle(
|
||||
foregroundColor: WidgetStatePropertyAll(PRIMARY_COLOR),
|
||||
side: WidgetStatePropertyAll(
|
||||
BorderSide(
|
||||
color: PRIMARY_COLOR,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
)
|
||||
),
|
||||
|
||||
elevatedButtonTheme: const ElevatedButtonThemeData(
|
||||
style: ButtonStyle(
|
||||
foregroundColor: WidgetStatePropertyAll(PRIMARY_COLOR),
|
||||
)
|
||||
),
|
||||
|
||||
outlinedButtonTheme: const OutlinedButtonThemeData(
|
||||
style: ButtonStyle(
|
||||
foregroundColor: WidgetStatePropertyAll(PRIMARY_COLOR),
|
||||
)
|
||||
),
|
||||
|
||||
|
||||
sliderTheme: const SliderThemeData(
|
||||
trackHeight: 15,
|
||||
inactiveTrackColor: Colors.grey,
|
||||
thumbColor: PRIMARY_COLOR,
|
||||
activeTrackColor: GRADIENT_END
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
const Gradient APP_GRADIENT = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [GRADIENT_START, GRADIENT_END],
|
||||
);
|
||||
|
||||
|
||||
final GlobalKey<ScaffoldMessengerState> rootScaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
16
frontend/lib/core/dio_client.dart
Normal file
16
frontend/lib/core/dio_client.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
class DioClient {
|
||||
final Dio dio;
|
||||
|
||||
DioClient({required String baseUrl}): dio = Dio(BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 120),
|
||||
// also accept 500 errors, since we cannot rule out that the server is at fault. We still want to gracefully handle these errors
|
||||
validateStatus: (status) => status! <= 500,
|
||||
receiveDataWhenStatusError: true,
|
||||
contentType: Headers.jsonContentType,
|
||||
responseType: ResponseType.json,
|
||||
));
|
||||
}
|
||||
0
frontend/lib/data/README.md
Normal file
0
frontend/lib/data/README.md
Normal file
83
frontend/lib/data/datasources/trip_local_datasource.dart
Normal file
83
frontend/lib/data/datasources/trip_local_datasource.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Defines the contract for persisting trip payloads locally.
|
||||
abstract class TripLocalDataSource {
|
||||
/// Returns all saved trip JSON payloads, newest first.
|
||||
Future<List<Map<String, dynamic>>> loadTrips();
|
||||
|
||||
/// Returns a single trip payload by uuid if present.
|
||||
/// TODO - should directly return Trip?
|
||||
Future<Map<String, dynamic>?> getTrip(String uuid);
|
||||
|
||||
/// Upserts the provided trip payload (also used for editing existing trips).
|
||||
Future<void> upsertTrip(Map<String, dynamic> tripJson);
|
||||
|
||||
/// Removes the trip with the matching uuid.
|
||||
Future<void> deleteTrip(String uuid);
|
||||
}
|
||||
|
||||
class TripLocalDataSourceImpl implements TripLocalDataSource {
|
||||
TripLocalDataSourceImpl({Future<SharedPreferences>? preferences})
|
||||
: _prefsFuture = preferences ?? SharedPreferences.getInstance();
|
||||
|
||||
static const String _storageKey = 'savedTrips';
|
||||
final Future<SharedPreferences> _prefsFuture;
|
||||
|
||||
@override
|
||||
Future<List<Map<String, dynamic>>> loadTrips() async {
|
||||
final prefs = await _prefsFuture;
|
||||
final stored = prefs.getStringList(_storageKey);
|
||||
if (stored == null) return [];
|
||||
return stored.map(_decodeTrip).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>?> getTrip(String uuid) async {
|
||||
final trips = await loadTrips();
|
||||
for (final trip in trips) {
|
||||
if (trip['uuid'] == uuid) {
|
||||
return Map<String, dynamic>.from(trip);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> upsertTrip(Map<String, dynamic> tripJson) async {
|
||||
final uuid = tripJson['uuid'];
|
||||
if (uuid is! String || uuid.isEmpty) {
|
||||
throw ArgumentError('Trip JSON must contain a uuid string');
|
||||
}
|
||||
|
||||
final trips = await loadTrips();
|
||||
trips.removeWhere((trip) => trip['uuid'] == uuid);
|
||||
trips.insert(0, Map<String, dynamic>.from(tripJson));
|
||||
await _persistTrips(trips);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteTrip(String uuid) async {
|
||||
final trips = await loadTrips();
|
||||
final updated = trips.where((trip) => trip['uuid'] != uuid).toList();
|
||||
if (updated.length == trips.length) {
|
||||
return;
|
||||
}
|
||||
await _persistTrips(updated);
|
||||
}
|
||||
|
||||
Future<void> _persistTrips(List<Map<String, dynamic>> trips) async {
|
||||
final prefs = await _prefsFuture;
|
||||
final payload = trips.map(jsonEncode).toList();
|
||||
await prefs.setStringList(_storageKey, payload);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _decodeTrip(String raw) {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw const FormatException('Saved trip entry is not a JSON object');
|
||||
}
|
||||
return Map<String, dynamic>.from(decoded);
|
||||
}
|
||||
}
|
||||
142
frontend/lib/data/datasources/trip_remote_datasource.dart
Normal file
142
frontend/lib/data/datasources/trip_remote_datasource.dart
Normal file
@@ -0,0 +1,142 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
abstract class TripRemoteDataSource {
|
||||
/// Fetch available landmarks for the provided preferences/start payload.
|
||||
Future<List<Map<String, dynamic>>> fetchLandmarks(Map<String, dynamic> body);
|
||||
|
||||
/// Create a new trip using the optimizer payload (that includes landmarks).
|
||||
Future<Map<String, dynamic>> createTrip(Map<String, dynamic> body);
|
||||
|
||||
/// Fetch an existing trip by UUID
|
||||
Future<Map<String, dynamic>> fetchTrip(String uuid);
|
||||
}
|
||||
|
||||
class TripRemoteDataSourceImpl implements TripRemoteDataSource {
|
||||
final Dio dio;
|
||||
|
||||
TripRemoteDataSourceImpl({required this.dio});
|
||||
|
||||
@override
|
||||
Future<List<Map<String, dynamic>>> fetchLandmarks(Map<String, dynamic> body) async {
|
||||
final response = await dio.post('/get/landmarks', data: body);
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Server error fetching landmarks: ${response.statusCode}');
|
||||
}
|
||||
|
||||
if (response.data is! List) {
|
||||
throw Exception('Unexpected landmarks response format');
|
||||
}
|
||||
|
||||
return _normalizeLandmarks(List<dynamic>.from(response.data as List));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> createTrip(Map<String, dynamic> body) async {
|
||||
log('Creating trip with body: $body');
|
||||
// convert body to actual json
|
||||
String bodyJson = jsonEncode(body);
|
||||
log('Trip request JSON: $bodyJson');
|
||||
final response = await dio.post('/optimize/trip', data: body);
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Server error: ${response.statusCode}');
|
||||
}
|
||||
|
||||
if (response.data is! Map<String, dynamic>) {
|
||||
throw Exception('Unexpected response format');
|
||||
}
|
||||
|
||||
final Map<String, dynamic> json = Map<String, dynamic>.from(response.data as Map);
|
||||
|
||||
_ensureLandmarks(json);
|
||||
if (json.containsKey('landmarks') && json['landmarks'] is List) {
|
||||
return json;
|
||||
}
|
||||
|
||||
final String? firstUuid = json['first_landmark_uuid'] as String?;
|
||||
if (firstUuid == null) {
|
||||
return json;
|
||||
}
|
||||
|
||||
final List<Map<String, dynamic>> landmarks = [];
|
||||
String? next = firstUuid;
|
||||
while (next != null) {
|
||||
final lm = await _fetchLandmarkByUuid(next);
|
||||
landmarks.add(lm);
|
||||
final dynamic nxt = lm['next_uuid'];
|
||||
next = (nxt is String) ? nxt : null;
|
||||
}
|
||||
|
||||
json['landmarks'] = landmarks;
|
||||
return json;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> fetchTrip(String uuid) async {
|
||||
final response = await dio.get('/trip/$uuid');
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Server error: ${response.statusCode}');
|
||||
}
|
||||
if (response.data is! Map<String, dynamic>) {
|
||||
throw Exception('Unexpected response format');
|
||||
}
|
||||
|
||||
final Map<String, dynamic> json = Map<String, dynamic>.from(response.data as Map);
|
||||
// Normalize same as createTrip: if trip contains first_landmark_uuid, expand chain
|
||||
if (json.containsKey('landmarks') && json['landmarks'] is List) {
|
||||
_ensureLandmarks(json);
|
||||
return json;
|
||||
}
|
||||
|
||||
final String? firstUuid = json['first_landmark_uuid'] as String?;
|
||||
if (firstUuid == null) return json;
|
||||
|
||||
final List<Map<String, dynamic>> landmarks = [];
|
||||
String? next = firstUuid;
|
||||
while (next != null) {
|
||||
final lm = await _fetchLandmarkByUuid(next);
|
||||
landmarks.add(lm);
|
||||
final dynamic nxt = lm['next_uuid'];
|
||||
next = (nxt is String) ? nxt : null;
|
||||
}
|
||||
|
||||
json['landmarks'] = landmarks;
|
||||
return json;
|
||||
}
|
||||
|
||||
// Fetch a single landmark by uuid via the /landmark/{landmark_uuid} endpoint
|
||||
Future<Map<String, dynamic>> _fetchLandmarkByUuid(String uuid) async {
|
||||
final response = await dio.get('/landmark/$uuid');
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to fetch landmark $uuid: ${response.statusCode}');
|
||||
}
|
||||
if (response.data is! Map<String, dynamic>) {
|
||||
throw Exception('Unexpected landmark response format');
|
||||
}
|
||||
return _normalizeLandmark(Map<String, dynamic>.from(response.data as Map));
|
||||
}
|
||||
|
||||
static void _ensureLandmarks(Map<String, dynamic> json) {
|
||||
final raw = json['landmarks'];
|
||||
if (raw is List) {
|
||||
json['landmarks'] = _normalizeLandmarks(raw);
|
||||
}
|
||||
}
|
||||
|
||||
static List<Map<String, dynamic>> _normalizeLandmarks(List<dynamic> rawList) {
|
||||
return rawList
|
||||
.map((item) => _normalizeLandmark(Map<String, dynamic>.from(item as Map)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _normalizeLandmark(Map<String, dynamic> source) {
|
||||
final normalized = Map<String, dynamic>.from(source);
|
||||
final typeValue = normalized['type'];
|
||||
if (typeValue is String) {
|
||||
normalized['type'] = {'type': typeValue};
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
32
frontend/lib/data/models/landmark_model.dart
Normal file
32
frontend/lib/data/models/landmark_model.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
// TODO - I have the feeling this file is outdated and not used anymore
|
||||
import 'package:anyway/domain/entities/landmark.dart';
|
||||
import 'package:anyway/domain/entities/landmark_description.dart';
|
||||
import 'package:anyway/domain/entities/landmark_type.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
part 'landmark_model.freezed.dart';
|
||||
part 'landmark_model.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class LandmarkModel with _$LandmarkModel {
|
||||
const factory LandmarkModel({
|
||||
required String uuid,
|
||||
required String name,
|
||||
required List<double> location,
|
||||
required String type,
|
||||
required bool isSecondary,
|
||||
required String description,
|
||||
}) = _LandmarkModel;
|
||||
|
||||
const LandmarkModel._();
|
||||
|
||||
factory LandmarkModel.fromJson(Map<String, dynamic> json) => _$LandmarkModelFromJson(json);
|
||||
|
||||
Landmark toEntity() => Landmark(
|
||||
uuid: uuid,
|
||||
name: name,
|
||||
location: location,
|
||||
type: LandmarkType(type: LandmarkTypeEnum.values.firstWhere((e) => e.value == type)),
|
||||
isSecondary: isSecondary,
|
||||
description: LandmarkDescription(description: description, tags: []),
|
||||
);
|
||||
}
|
||||
298
frontend/lib/data/models/landmark_model.freezed.dart
Normal file
298
frontend/lib/data/models/landmark_model.freezed.dart
Normal file
@@ -0,0 +1,298 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// 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 'landmark_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$LandmarkModel {
|
||||
|
||||
String get uuid; String get name; List<double> get location; String get type; bool get isSecondary; String get description;
|
||||
/// Create a copy of LandmarkModel
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$LandmarkModelCopyWith<LandmarkModel> get copyWith => _$LandmarkModelCopyWithImpl<LandmarkModel>(this as LandmarkModel, _$identity);
|
||||
|
||||
/// Serializes this LandmarkModel to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is LandmarkModel&&(identical(other.uuid, uuid) || other.uuid == uuid)&&(identical(other.name, name) || other.name == name)&&const DeepCollectionEquality().equals(other.location, location)&&(identical(other.type, type) || other.type == type)&&(identical(other.isSecondary, isSecondary) || other.isSecondary == isSecondary)&&(identical(other.description, description) || other.description == description));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,uuid,name,const DeepCollectionEquality().hash(location),type,isSecondary,description);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'LandmarkModel(uuid: $uuid, name: $name, location: $location, type: $type, isSecondary: $isSecondary, description: $description)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $LandmarkModelCopyWith<$Res> {
|
||||
factory $LandmarkModelCopyWith(LandmarkModel value, $Res Function(LandmarkModel) _then) = _$LandmarkModelCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String uuid, String name, List<double> location, String type, bool isSecondary, String description
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$LandmarkModelCopyWithImpl<$Res>
|
||||
implements $LandmarkModelCopyWith<$Res> {
|
||||
_$LandmarkModelCopyWithImpl(this._self, this._then);
|
||||
|
||||
final LandmarkModel _self;
|
||||
final $Res Function(LandmarkModel) _then;
|
||||
|
||||
/// Create a copy of LandmarkModel
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? uuid = null,Object? name = null,Object? location = null,Object? type = null,Object? isSecondary = null,Object? description = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
uuid: null == uuid ? _self.uuid : uuid // ignore: cast_nullable_to_non_nullable
|
||||
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
|
||||
as String,location: null == location ? _self.location : location // ignore: cast_nullable_to_non_nullable
|
||||
as List<double>,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||
as String,isSecondary: null == isSecondary ? _self.isSecondary : isSecondary // ignore: cast_nullable_to_non_nullable
|
||||
as bool,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [LandmarkModel].
|
||||
extension LandmarkModelPatterns on LandmarkModel {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _LandmarkModel value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _LandmarkModel() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _LandmarkModel value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _LandmarkModel():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _LandmarkModel value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _LandmarkModel() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String uuid, String name, List<double> location, String type, bool isSecondary, String description)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _LandmarkModel() when $default != null:
|
||||
return $default(_that.uuid,_that.name,_that.location,_that.type,_that.isSecondary,_that.description);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String uuid, String name, List<double> location, String type, bool isSecondary, String description) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _LandmarkModel():
|
||||
return $default(_that.uuid,_that.name,_that.location,_that.type,_that.isSecondary,_that.description);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String uuid, String name, List<double> location, String type, bool isSecondary, String description)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _LandmarkModel() when $default != null:
|
||||
return $default(_that.uuid,_that.name,_that.location,_that.type,_that.isSecondary,_that.description);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _LandmarkModel extends LandmarkModel {
|
||||
const _LandmarkModel({required this.uuid, required this.name, required final List<double> location, required this.type, required this.isSecondary, required this.description}): _location = location,super._();
|
||||
factory _LandmarkModel.fromJson(Map<String, dynamic> json) => _$LandmarkModelFromJson(json);
|
||||
|
||||
@override final String uuid;
|
||||
@override final String name;
|
||||
final List<double> _location;
|
||||
@override List<double> get location {
|
||||
if (_location is EqualUnmodifiableListView) return _location;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_location);
|
||||
}
|
||||
|
||||
@override final String type;
|
||||
@override final bool isSecondary;
|
||||
@override final String description;
|
||||
|
||||
/// Create a copy of LandmarkModel
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$LandmarkModelCopyWith<_LandmarkModel> get copyWith => __$LandmarkModelCopyWithImpl<_LandmarkModel>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$LandmarkModelToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _LandmarkModel&&(identical(other.uuid, uuid) || other.uuid == uuid)&&(identical(other.name, name) || other.name == name)&&const DeepCollectionEquality().equals(other._location, _location)&&(identical(other.type, type) || other.type == type)&&(identical(other.isSecondary, isSecondary) || other.isSecondary == isSecondary)&&(identical(other.description, description) || other.description == description));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,uuid,name,const DeepCollectionEquality().hash(_location),type,isSecondary,description);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'LandmarkModel(uuid: $uuid, name: $name, location: $location, type: $type, isSecondary: $isSecondary, description: $description)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$LandmarkModelCopyWith<$Res> implements $LandmarkModelCopyWith<$Res> {
|
||||
factory _$LandmarkModelCopyWith(_LandmarkModel value, $Res Function(_LandmarkModel) _then) = __$LandmarkModelCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String uuid, String name, List<double> location, String type, bool isSecondary, String description
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$LandmarkModelCopyWithImpl<$Res>
|
||||
implements _$LandmarkModelCopyWith<$Res> {
|
||||
__$LandmarkModelCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _LandmarkModel _self;
|
||||
final $Res Function(_LandmarkModel) _then;
|
||||
|
||||
/// Create a copy of LandmarkModel
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? uuid = null,Object? name = null,Object? location = null,Object? type = null,Object? isSecondary = null,Object? description = null,}) {
|
||||
return _then(_LandmarkModel(
|
||||
uuid: null == uuid ? _self.uuid : uuid // ignore: cast_nullable_to_non_nullable
|
||||
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
|
||||
as String,location: null == location ? _self._location : location // ignore: cast_nullable_to_non_nullable
|
||||
as List<double>,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||
as String,isSecondary: null == isSecondary ? _self.isSecondary : isSecondary // ignore: cast_nullable_to_non_nullable
|
||||
as bool,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
29
frontend/lib/data/models/landmark_model.g.dart
Normal file
29
frontend/lib/data/models/landmark_model.g.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'landmark_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_LandmarkModel _$LandmarkModelFromJson(Map<String, dynamic> json) =>
|
||||
_LandmarkModel(
|
||||
uuid: json['uuid'] as String,
|
||||
name: json['name'] as String,
|
||||
location: (json['location'] as List<dynamic>)
|
||||
.map((e) => (e as num).toDouble())
|
||||
.toList(),
|
||||
type: json['type'] as String,
|
||||
isSecondary: json['isSecondary'] as bool,
|
||||
description: json['description'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$LandmarkModelToJson(_LandmarkModel instance) =>
|
||||
<String, dynamic>{
|
||||
'uuid': instance.uuid,
|
||||
'name': instance.name,
|
||||
'location': instance.location,
|
||||
'type': instance.type,
|
||||
'isSecondary': instance.isSecondary,
|
||||
'description': instance.description,
|
||||
};
|
||||
114
frontend/lib/data/repositories/backend_trip_repository.dart
Normal file
114
frontend/lib/data/repositories/backend_trip_repository.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
import 'package:anyway/data/datasources/trip_local_datasource.dart';
|
||||
import 'package:anyway/data/datasources/trip_remote_datasource.dart';
|
||||
import 'package:anyway/domain/entities/landmark.dart';
|
||||
import 'package:anyway/domain/entities/preferences.dart';
|
||||
import 'package:anyway/domain/entities/trip.dart';
|
||||
import 'package:anyway/domain/repositories/trip_repository.dart';
|
||||
|
||||
class BackendTripRepository implements TripRepository {
|
||||
final TripRemoteDataSource remote;
|
||||
final TripLocalDataSource local;
|
||||
|
||||
BackendTripRepository({required this.remote, required this.local});
|
||||
|
||||
@override
|
||||
Future<Trip> getTrip({Preferences? preferences, String? tripUUID, List<Landmark>? landmarks}) async {
|
||||
try {
|
||||
Map<String, dynamic> json;
|
||||
|
||||
if (tripUUID != null) {
|
||||
json = await remote.fetchTrip(tripUUID);
|
||||
} else {
|
||||
if (preferences == null) {
|
||||
throw ArgumentError('Either preferences or tripUUID must be provided');
|
||||
}
|
||||
|
||||
final Map<String, dynamic> prefsPayload = _buildPreferencesPayload(preferences);
|
||||
List<Map<String, dynamic>> landmarkBodies = landmarks != null
|
||||
? landmarks.map((lm) => lm.toJson()).toList()
|
||||
: await _fetchLandmarkPayloads(prefsPayload, preferences.startLocation);
|
||||
|
||||
// TODO: remove
|
||||
// restrict the landmark list to 30 to iterate quickly
|
||||
landmarkBodies = landmarkBodies.take(30).toList();
|
||||
// change the json key because of backend inconsistency
|
||||
for (var lm in landmarkBodies) {
|
||||
if (lm.containsKey('type')) {
|
||||
lm['type'] = "sightseeing";
|
||||
}
|
||||
lm['osm_type'] = 'node';
|
||||
lm['osm_id'] = 1;
|
||||
}
|
||||
|
||||
final Map<String, dynamic> body = {
|
||||
'preferences': prefsPayload,
|
||||
'landmarks': landmarkBodies,
|
||||
'start': preferences.startLocation,
|
||||
};
|
||||
if (preferences.endLocation != null) {
|
||||
body['end'] = preferences.endLocation;
|
||||
}
|
||||
if (preferences.detourToleranceMinutes != null) {
|
||||
body['detour_tolerance_minute'] = preferences.detourToleranceMinutes;
|
||||
}
|
||||
|
||||
json = await remote.createTrip(body);
|
||||
}
|
||||
|
||||
return Trip.fromJson(json);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to fetch trip: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// TODO - maybe shorten this
|
||||
@override
|
||||
Future<List<Landmark>> searchLandmarks(Preferences preferences) async {
|
||||
final Map<String, dynamic> prefsPayload = _buildPreferencesPayload(preferences);
|
||||
final List<Map<String, dynamic>> rawLandmarks = await _fetchLandmarkPayloads(prefsPayload, preferences.startLocation);
|
||||
return rawLandmarks.map((lmJson) => Landmark.fromJson(lmJson)).toList();
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> _fetchLandmarkPayloads(
|
||||
Map<String, dynamic> prefsPayload, List<double> startLocation) async {
|
||||
final Map<String, dynamic> landmarkRequest = {
|
||||
'preferences': prefsPayload,
|
||||
'start': startLocation,
|
||||
};
|
||||
|
||||
return await remote.fetchLandmarks(landmarkRequest);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _buildPreferencesPayload(Preferences preferences) {
|
||||
final Map<String, dynamic> prefsPayload = {};
|
||||
preferences.scores.forEach((type, score) {
|
||||
prefsPayload[type] = {'type': type, 'score': score};
|
||||
});
|
||||
prefsPayload['max_time_minute'] = preferences.maxTimeMinutes;
|
||||
return prefsPayload;
|
||||
}
|
||||
|
||||
// TODO - should this be moved to a separate local repository?
|
||||
@override
|
||||
Future<List<Trip>> getSavedTrips() async {
|
||||
final rawTrips = await local.loadTrips();
|
||||
return rawTrips.map(Trip.fromJson).toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Trip?> getSavedTrip(String uuid) async {
|
||||
final json = await local.getTrip(uuid);
|
||||
return json == null ? null : Trip.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveTrip(Trip trip) async {
|
||||
await local.upsertTrip(trip.toJson());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteSavedTrip(String uuid) async {
|
||||
await local.deleteTrip(uuid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:anyway/domain/repositories/onboarding_repository.dart';
|
||||
|
||||
class LocalOnboardingRepository implements OnboardingRepository {
|
||||
static const _key = 'onboardingCompleted';
|
||||
|
||||
@override
|
||||
Future<bool> isOnboarded() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_key) ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setOnboarded(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_key, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:anyway/domain/entities/preferences.dart';
|
||||
import 'package:anyway/domain/repositories/preferences_repository.dart';
|
||||
|
||||
class PreferencesRepositoryImpl implements PreferencesRepository {
|
||||
static const _key = 'userPreferences';
|
||||
|
||||
@override
|
||||
Future<Preferences> getPreferences() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_key);
|
||||
if (raw == null) {
|
||||
// TODO - rethink this
|
||||
// return a sensible default
|
||||
return Preferences(
|
||||
scores: {
|
||||
'sightseeing': 0,
|
||||
'shopping': 0,
|
||||
'nature': 0,
|
||||
},
|
||||
maxTimeMinutes: 120,
|
||||
startLocation: const [48.8575, 2.3514],
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final map = json.decode(raw) as Map<String, dynamic>;
|
||||
return Preferences.fromJson(map);
|
||||
} catch (_) {
|
||||
return Preferences(
|
||||
scores: {
|
||||
'sightseeing': 0,
|
||||
'shopping': 0,
|
||||
'nature': 0,
|
||||
},
|
||||
maxTimeMinutes: 120,
|
||||
startLocation: const [48.8575, 2.3514],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> savePreferences(Preferences preferences) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = json.encode(preferences.toJson());
|
||||
await prefs.setString(_key, raw);
|
||||
}
|
||||
}
|
||||
35
frontend/lib/domain/README.md
Normal file
35
frontend/lib/domain/README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Domain layer
|
||||
|
||||
## `entities` - Model definition
|
||||
|
||||
Since we follow the repository structure convention, in this folder we purely define data models, without providing any logic. To reduce boilerplate, we use the `freezed` package. This requires some code generation, which means that all model definitions have the following structure:
|
||||
```dart
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
// required: associates our `main.dart` with the code generated by Freezed
|
||||
part 'main.freezed.dart';
|
||||
// optional: Since our Person class is serializable, we must add this line.
|
||||
// But if Person was not serializable, we could skip it.
|
||||
part 'main.g.dart';
|
||||
```
|
||||
|
||||
This is required boilerplate for all models. Then, we define the model itself using the `@freezed` annotation:
|
||||
```dart
|
||||
@freezed
|
||||
...
|
||||
```
|
||||
|
||||
The `*.frozen.dart` and `*.g.dart` are pure boilerplate and should not be touched.
|
||||
|
||||
Note that the description of the data will losely follow the capabilities of the backend but does not need to reflect it exactly. That is where the `data` part is for: translating api calls into flutter objects.
|
||||
|
||||
To ensure the creation of the boilerplate code, run the following command in your terminal:
|
||||
|
||||
```bash
|
||||
flutter pub run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
or interactively:
|
||||
```
|
||||
dart run build_runner watch -d
|
||||
```
|
||||
|
||||
56
frontend/lib/domain/entities/landmark.dart
Normal file
56
frontend/lib/domain/entities/landmark.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
import 'package:anyway/domain/entities/landmark_type.dart';
|
||||
import 'package:anyway/domain/entities/landmark_description.dart';
|
||||
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
part 'landmark.freezed.dart';
|
||||
part 'landmark.g.dart';
|
||||
|
||||
@unfreezed
|
||||
abstract class Landmark with _$Landmark {
|
||||
factory Landmark({
|
||||
required String uuid,
|
||||
required String name,
|
||||
required List<double> location,
|
||||
required LandmarkType type,
|
||||
|
||||
@JsonKey(name: 'is_secondary')
|
||||
bool? isSecondary,
|
||||
|
||||
/// Optional rich description object (may be null if API returns plain string)
|
||||
LandmarkDescription? description,
|
||||
|
||||
@JsonKey(name: 'name_en')
|
||||
String? nameEn,
|
||||
|
||||
@JsonKey(name: 'website_url')
|
||||
String? websiteUrl,
|
||||
|
||||
@JsonKey(name: 'image_url')
|
||||
String? imageUrl,
|
||||
|
||||
@JsonKey(name: 'attractiveness')
|
||||
int? attractiveness,
|
||||
|
||||
@JsonKey(name: 'n_tags')
|
||||
int? tagCount,
|
||||
|
||||
|
||||
|
||||
/// Duration at landmark in minutes
|
||||
@JsonKey(name: 'duration')
|
||||
int? durationMinutes,
|
||||
|
||||
bool? visited,
|
||||
|
||||
@JsonKey(name: 'time_to_reach_next')
|
||||
int? timeToReachNextMinutes,
|
||||
}) = _Landmark;
|
||||
|
||||
factory Landmark.fromJson(Map<String, Object?> json) => _$LandmarkFromJson(json);
|
||||
}
|
||||
|
||||
extension LandmarkVisitX on Landmark {
|
||||
bool get isVisited => visited ?? false;
|
||||
|
||||
set isVisited(bool value) => visited = value;
|
||||
}
|
||||
350
frontend/lib/domain/entities/landmark.freezed.dart
Normal file
350
frontend/lib/domain/entities/landmark.freezed.dart
Normal file
@@ -0,0 +1,350 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// 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 'landmark.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$Landmark {
|
||||
|
||||
String get uuid; set uuid(String value); String get name; set name(String value); List<double> get location; set location(List<double> value); LandmarkType get type; set type(LandmarkType value);@JsonKey(name: 'is_secondary') bool? get isSecondary;@JsonKey(name: 'is_secondary') set isSecondary(bool? value);/// Optional rich description object (may be null if API returns plain string)
|
||||
LandmarkDescription? get description;/// Optional rich description object (may be null if API returns plain string)
|
||||
set description(LandmarkDescription? value);@JsonKey(name: 'name_en') String? get nameEn;@JsonKey(name: 'name_en') set nameEn(String? value);@JsonKey(name: 'website_url') String? get websiteUrl;@JsonKey(name: 'website_url') set websiteUrl(String? value);@JsonKey(name: 'image_url') String? get imageUrl;@JsonKey(name: 'image_url') set imageUrl(String? value);@JsonKey(name: 'attractiveness') int? get attractiveness;@JsonKey(name: 'attractiveness') set attractiveness(int? value);@JsonKey(name: 'n_tags') int? get tagCount;@JsonKey(name: 'n_tags') set tagCount(int? value);/// Duration at landmark in minutes
|
||||
@JsonKey(name: 'duration') int? get durationMinutes;/// Duration at landmark in minutes
|
||||
@JsonKey(name: 'duration') set durationMinutes(int? value); bool? get visited; set visited(bool? value);@JsonKey(name: 'time_to_reach_next') int? get timeToReachNextMinutes;@JsonKey(name: 'time_to_reach_next') set timeToReachNextMinutes(int? value);
|
||||
/// Create a copy of Landmark
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$LandmarkCopyWith<Landmark> get copyWith => _$LandmarkCopyWithImpl<Landmark>(this as Landmark, _$identity);
|
||||
|
||||
/// Serializes this Landmark to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Landmark(uuid: $uuid, name: $name, location: $location, type: $type, isSecondary: $isSecondary, description: $description, nameEn: $nameEn, websiteUrl: $websiteUrl, imageUrl: $imageUrl, attractiveness: $attractiveness, tagCount: $tagCount, durationMinutes: $durationMinutes, visited: $visited, timeToReachNextMinutes: $timeToReachNextMinutes)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $LandmarkCopyWith<$Res> {
|
||||
factory $LandmarkCopyWith(Landmark value, $Res Function(Landmark) _then) = _$LandmarkCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String uuid, String name, List<double> location, LandmarkType type,@JsonKey(name: 'is_secondary') bool? isSecondary, LandmarkDescription? description,@JsonKey(name: 'name_en') String? nameEn,@JsonKey(name: 'website_url') String? websiteUrl,@JsonKey(name: 'image_url') String? imageUrl,@JsonKey(name: 'attractiveness') int? attractiveness,@JsonKey(name: 'n_tags') int? tagCount,@JsonKey(name: 'duration') int? durationMinutes, bool? visited,@JsonKey(name: 'time_to_reach_next') int? timeToReachNextMinutes
|
||||
});
|
||||
|
||||
|
||||
$LandmarkTypeCopyWith<$Res> get type;$LandmarkDescriptionCopyWith<$Res>? get description;
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$LandmarkCopyWithImpl<$Res>
|
||||
implements $LandmarkCopyWith<$Res> {
|
||||
_$LandmarkCopyWithImpl(this._self, this._then);
|
||||
|
||||
final Landmark _self;
|
||||
final $Res Function(Landmark) _then;
|
||||
|
||||
/// Create a copy of Landmark
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? uuid = null,Object? name = null,Object? location = null,Object? type = null,Object? isSecondary = freezed,Object? description = freezed,Object? nameEn = freezed,Object? websiteUrl = freezed,Object? imageUrl = freezed,Object? attractiveness = freezed,Object? tagCount = freezed,Object? durationMinutes = freezed,Object? visited = freezed,Object? timeToReachNextMinutes = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
uuid: null == uuid ? _self.uuid : uuid // ignore: cast_nullable_to_non_nullable
|
||||
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
|
||||
as String,location: null == location ? _self.location : location // ignore: cast_nullable_to_non_nullable
|
||||
as List<double>,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||
as LandmarkType,isSecondary: freezed == isSecondary ? _self.isSecondary : isSecondary // ignore: cast_nullable_to_non_nullable
|
||||
as bool?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
|
||||
as LandmarkDescription?,nameEn: freezed == nameEn ? _self.nameEn : nameEn // ignore: cast_nullable_to_non_nullable
|
||||
as String?,websiteUrl: freezed == websiteUrl ? _self.websiteUrl : websiteUrl // ignore: cast_nullable_to_non_nullable
|
||||
as String?,imageUrl: freezed == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable
|
||||
as String?,attractiveness: freezed == attractiveness ? _self.attractiveness : attractiveness // ignore: cast_nullable_to_non_nullable
|
||||
as int?,tagCount: freezed == tagCount ? _self.tagCount : tagCount // ignore: cast_nullable_to_non_nullable
|
||||
as int?,durationMinutes: freezed == durationMinutes ? _self.durationMinutes : durationMinutes // ignore: cast_nullable_to_non_nullable
|
||||
as int?,visited: freezed == visited ? _self.visited : visited // ignore: cast_nullable_to_non_nullable
|
||||
as bool?,timeToReachNextMinutes: freezed == timeToReachNextMinutes ? _self.timeToReachNextMinutes : timeToReachNextMinutes // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
));
|
||||
}
|
||||
/// Create a copy of Landmark
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$LandmarkTypeCopyWith<$Res> get type {
|
||||
|
||||
return $LandmarkTypeCopyWith<$Res>(_self.type, (value) {
|
||||
return _then(_self.copyWith(type: value));
|
||||
});
|
||||
}/// Create a copy of Landmark
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$LandmarkDescriptionCopyWith<$Res>? get description {
|
||||
if (_self.description == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $LandmarkDescriptionCopyWith<$Res>(_self.description!, (value) {
|
||||
return _then(_self.copyWith(description: value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [Landmark].
|
||||
extension LandmarkPatterns on Landmark {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _Landmark value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _Landmark() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _Landmark value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _Landmark():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _Landmark value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _Landmark() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String uuid, String name, List<double> location, LandmarkType type, @JsonKey(name: 'is_secondary') bool? isSecondary, LandmarkDescription? description, @JsonKey(name: 'name_en') String? nameEn, @JsonKey(name: 'website_url') String? websiteUrl, @JsonKey(name: 'image_url') String? imageUrl, @JsonKey(name: 'attractiveness') int? attractiveness, @JsonKey(name: 'n_tags') int? tagCount, @JsonKey(name: 'duration') int? durationMinutes, bool? visited, @JsonKey(name: 'time_to_reach_next') int? timeToReachNextMinutes)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _Landmark() when $default != null:
|
||||
return $default(_that.uuid,_that.name,_that.location,_that.type,_that.isSecondary,_that.description,_that.nameEn,_that.websiteUrl,_that.imageUrl,_that.attractiveness,_that.tagCount,_that.durationMinutes,_that.visited,_that.timeToReachNextMinutes);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String uuid, String name, List<double> location, LandmarkType type, @JsonKey(name: 'is_secondary') bool? isSecondary, LandmarkDescription? description, @JsonKey(name: 'name_en') String? nameEn, @JsonKey(name: 'website_url') String? websiteUrl, @JsonKey(name: 'image_url') String? imageUrl, @JsonKey(name: 'attractiveness') int? attractiveness, @JsonKey(name: 'n_tags') int? tagCount, @JsonKey(name: 'duration') int? durationMinutes, bool? visited, @JsonKey(name: 'time_to_reach_next') int? timeToReachNextMinutes) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _Landmark():
|
||||
return $default(_that.uuid,_that.name,_that.location,_that.type,_that.isSecondary,_that.description,_that.nameEn,_that.websiteUrl,_that.imageUrl,_that.attractiveness,_that.tagCount,_that.durationMinutes,_that.visited,_that.timeToReachNextMinutes);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String uuid, String name, List<double> location, LandmarkType type, @JsonKey(name: 'is_secondary') bool? isSecondary, LandmarkDescription? description, @JsonKey(name: 'name_en') String? nameEn, @JsonKey(name: 'website_url') String? websiteUrl, @JsonKey(name: 'image_url') String? imageUrl, @JsonKey(name: 'attractiveness') int? attractiveness, @JsonKey(name: 'n_tags') int? tagCount, @JsonKey(name: 'duration') int? durationMinutes, bool? visited, @JsonKey(name: 'time_to_reach_next') int? timeToReachNextMinutes)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _Landmark() when $default != null:
|
||||
return $default(_that.uuid,_that.name,_that.location,_that.type,_that.isSecondary,_that.description,_that.nameEn,_that.websiteUrl,_that.imageUrl,_that.attractiveness,_that.tagCount,_that.durationMinutes,_that.visited,_that.timeToReachNextMinutes);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _Landmark implements Landmark {
|
||||
_Landmark({required this.uuid, required this.name, required this.location, required this.type, @JsonKey(name: 'is_secondary') this.isSecondary, this.description, @JsonKey(name: 'name_en') this.nameEn, @JsonKey(name: 'website_url') this.websiteUrl, @JsonKey(name: 'image_url') this.imageUrl, @JsonKey(name: 'attractiveness') this.attractiveness, @JsonKey(name: 'n_tags') this.tagCount, @JsonKey(name: 'duration') this.durationMinutes, this.visited, @JsonKey(name: 'time_to_reach_next') this.timeToReachNextMinutes});
|
||||
factory _Landmark.fromJson(Map<String, dynamic> json) => _$LandmarkFromJson(json);
|
||||
|
||||
@override String uuid;
|
||||
@override String name;
|
||||
@override List<double> location;
|
||||
@override LandmarkType type;
|
||||
@override@JsonKey(name: 'is_secondary') bool? isSecondary;
|
||||
/// Optional rich description object (may be null if API returns plain string)
|
||||
@override LandmarkDescription? description;
|
||||
@override@JsonKey(name: 'name_en') String? nameEn;
|
||||
@override@JsonKey(name: 'website_url') String? websiteUrl;
|
||||
@override@JsonKey(name: 'image_url') String? imageUrl;
|
||||
@override@JsonKey(name: 'attractiveness') int? attractiveness;
|
||||
@override@JsonKey(name: 'n_tags') int? tagCount;
|
||||
/// Duration at landmark in minutes
|
||||
@override@JsonKey(name: 'duration') int? durationMinutes;
|
||||
@override bool? visited;
|
||||
@override@JsonKey(name: 'time_to_reach_next') int? timeToReachNextMinutes;
|
||||
|
||||
/// Create a copy of Landmark
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$LandmarkCopyWith<_Landmark> get copyWith => __$LandmarkCopyWithImpl<_Landmark>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$LandmarkToJson(this, );
|
||||
}
|
||||
|
||||
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Landmark(uuid: $uuid, name: $name, location: $location, type: $type, isSecondary: $isSecondary, description: $description, nameEn: $nameEn, websiteUrl: $websiteUrl, imageUrl: $imageUrl, attractiveness: $attractiveness, tagCount: $tagCount, durationMinutes: $durationMinutes, visited: $visited, timeToReachNextMinutes: $timeToReachNextMinutes)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$LandmarkCopyWith<$Res> implements $LandmarkCopyWith<$Res> {
|
||||
factory _$LandmarkCopyWith(_Landmark value, $Res Function(_Landmark) _then) = __$LandmarkCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String uuid, String name, List<double> location, LandmarkType type,@JsonKey(name: 'is_secondary') bool? isSecondary, LandmarkDescription? description,@JsonKey(name: 'name_en') String? nameEn,@JsonKey(name: 'website_url') String? websiteUrl,@JsonKey(name: 'image_url') String? imageUrl,@JsonKey(name: 'attractiveness') int? attractiveness,@JsonKey(name: 'n_tags') int? tagCount,@JsonKey(name: 'duration') int? durationMinutes, bool? visited,@JsonKey(name: 'time_to_reach_next') int? timeToReachNextMinutes
|
||||
});
|
||||
|
||||
|
||||
@override $LandmarkTypeCopyWith<$Res> get type;@override $LandmarkDescriptionCopyWith<$Res>? get description;
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$LandmarkCopyWithImpl<$Res>
|
||||
implements _$LandmarkCopyWith<$Res> {
|
||||
__$LandmarkCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _Landmark _self;
|
||||
final $Res Function(_Landmark) _then;
|
||||
|
||||
/// Create a copy of Landmark
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? uuid = null,Object? name = null,Object? location = null,Object? type = null,Object? isSecondary = freezed,Object? description = freezed,Object? nameEn = freezed,Object? websiteUrl = freezed,Object? imageUrl = freezed,Object? attractiveness = freezed,Object? tagCount = freezed,Object? durationMinutes = freezed,Object? visited = freezed,Object? timeToReachNextMinutes = freezed,}) {
|
||||
return _then(_Landmark(
|
||||
uuid: null == uuid ? _self.uuid : uuid // ignore: cast_nullable_to_non_nullable
|
||||
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
|
||||
as String,location: null == location ? _self.location : location // ignore: cast_nullable_to_non_nullable
|
||||
as List<double>,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||
as LandmarkType,isSecondary: freezed == isSecondary ? _self.isSecondary : isSecondary // ignore: cast_nullable_to_non_nullable
|
||||
as bool?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
|
||||
as LandmarkDescription?,nameEn: freezed == nameEn ? _self.nameEn : nameEn // ignore: cast_nullable_to_non_nullable
|
||||
as String?,websiteUrl: freezed == websiteUrl ? _self.websiteUrl : websiteUrl // ignore: cast_nullable_to_non_nullable
|
||||
as String?,imageUrl: freezed == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable
|
||||
as String?,attractiveness: freezed == attractiveness ? _self.attractiveness : attractiveness // ignore: cast_nullable_to_non_nullable
|
||||
as int?,tagCount: freezed == tagCount ? _self.tagCount : tagCount // ignore: cast_nullable_to_non_nullable
|
||||
as int?,durationMinutes: freezed == durationMinutes ? _self.durationMinutes : durationMinutes // ignore: cast_nullable_to_non_nullable
|
||||
as int?,visited: freezed == visited ? _self.visited : visited // ignore: cast_nullable_to_non_nullable
|
||||
as bool?,timeToReachNextMinutes: freezed == timeToReachNextMinutes ? _self.timeToReachNextMinutes : timeToReachNextMinutes // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
));
|
||||
}
|
||||
|
||||
/// Create a copy of Landmark
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$LandmarkTypeCopyWith<$Res> get type {
|
||||
|
||||
return $LandmarkTypeCopyWith<$Res>(_self.type, (value) {
|
||||
return _then(_self.copyWith(type: value));
|
||||
});
|
||||
}/// Create a copy of Landmark
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$LandmarkDescriptionCopyWith<$Res>? get description {
|
||||
if (_self.description == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $LandmarkDescriptionCopyWith<$Res>(_self.description!, (value) {
|
||||
return _then(_self.copyWith(description: value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// dart format on
|
||||
47
frontend/lib/domain/entities/landmark.g.dart
Normal file
47
frontend/lib/domain/entities/landmark.g.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'landmark.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_Landmark _$LandmarkFromJson(Map<String, dynamic> json) => _Landmark(
|
||||
uuid: json['uuid'] as String,
|
||||
name: json['name'] as String,
|
||||
location: (json['location'] as List<dynamic>)
|
||||
.map((e) => (e as num).toDouble())
|
||||
.toList(),
|
||||
type: LandmarkType.fromJson(json['type'] as Map<String, dynamic>),
|
||||
isSecondary: json['is_secondary'] as bool?,
|
||||
description: json['description'] == null
|
||||
? null
|
||||
: LandmarkDescription.fromJson(
|
||||
json['description'] as Map<String, dynamic>,
|
||||
),
|
||||
nameEn: json['name_en'] as String?,
|
||||
websiteUrl: json['website_url'] as String?,
|
||||
imageUrl: json['image_url'] as String?,
|
||||
attractiveness: (json['attractiveness'] as num?)?.toInt(),
|
||||
tagCount: (json['n_tags'] as num?)?.toInt(),
|
||||
durationMinutes: (json['duration'] as num?)?.toInt(),
|
||||
visited: json['visited'] as bool?,
|
||||
timeToReachNextMinutes: (json['time_to_reach_next'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$LandmarkToJson(_Landmark instance) => <String, dynamic>{
|
||||
'uuid': instance.uuid,
|
||||
'name': instance.name,
|
||||
'location': instance.location,
|
||||
'type': instance.type,
|
||||
'is_secondary': instance.isSecondary,
|
||||
'description': instance.description,
|
||||
'name_en': instance.nameEn,
|
||||
'website_url': instance.websiteUrl,
|
||||
'image_url': instance.imageUrl,
|
||||
'attractiveness': instance.attractiveness,
|
||||
'n_tags': instance.tagCount,
|
||||
'duration': instance.durationMinutes,
|
||||
'visited': instance.visited,
|
||||
'time_to_reach_next': instance.timeToReachNextMinutes,
|
||||
};
|
||||
15
frontend/lib/domain/entities/landmark_description.dart
Normal file
15
frontend/lib/domain/entities/landmark_description.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
part 'landmark_description.freezed.dart';
|
||||
part 'landmark_description.g.dart';
|
||||
|
||||
|
||||
@freezed
|
||||
abstract class LandmarkDescription with _$LandmarkDescription {
|
||||
const factory LandmarkDescription({
|
||||
required String description,
|
||||
required List<String> tags,
|
||||
}) = _LandmarkDescription;
|
||||
|
||||
factory LandmarkDescription.fromJson(Map<String, Object?> json) => _$LandmarkDescriptionFromJson(json);
|
||||
}
|
||||
|
||||
286
frontend/lib/domain/entities/landmark_description.freezed.dart
Normal file
286
frontend/lib/domain/entities/landmark_description.freezed.dart
Normal file
@@ -0,0 +1,286 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// 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 'landmark_description.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$LandmarkDescription {
|
||||
|
||||
String get description; List<String> get tags;
|
||||
/// Create a copy of LandmarkDescription
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$LandmarkDescriptionCopyWith<LandmarkDescription> get copyWith => _$LandmarkDescriptionCopyWithImpl<LandmarkDescription>(this as LandmarkDescription, _$identity);
|
||||
|
||||
/// Serializes this LandmarkDescription to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is LandmarkDescription&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other.tags, tags));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,description,const DeepCollectionEquality().hash(tags));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'LandmarkDescription(description: $description, tags: $tags)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $LandmarkDescriptionCopyWith<$Res> {
|
||||
factory $LandmarkDescriptionCopyWith(LandmarkDescription value, $Res Function(LandmarkDescription) _then) = _$LandmarkDescriptionCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String description, List<String> tags
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$LandmarkDescriptionCopyWithImpl<$Res>
|
||||
implements $LandmarkDescriptionCopyWith<$Res> {
|
||||
_$LandmarkDescriptionCopyWithImpl(this._self, this._then);
|
||||
|
||||
final LandmarkDescription _self;
|
||||
final $Res Function(LandmarkDescription) _then;
|
||||
|
||||
/// Create a copy of LandmarkDescription
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? description = null,Object? tags = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
|
||||
as String,tags: null == tags ? _self.tags : tags // ignore: cast_nullable_to_non_nullable
|
||||
as List<String>,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [LandmarkDescription].
|
||||
extension LandmarkDescriptionPatterns on LandmarkDescription {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _LandmarkDescription value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _LandmarkDescription() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _LandmarkDescription value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _LandmarkDescription():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _LandmarkDescription value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _LandmarkDescription() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String description, List<String> tags)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _LandmarkDescription() when $default != null:
|
||||
return $default(_that.description,_that.tags);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String description, List<String> tags) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _LandmarkDescription():
|
||||
return $default(_that.description,_that.tags);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String description, List<String> tags)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _LandmarkDescription() when $default != null:
|
||||
return $default(_that.description,_that.tags);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _LandmarkDescription implements LandmarkDescription {
|
||||
const _LandmarkDescription({required this.description, required final List<String> tags}): _tags = tags;
|
||||
factory _LandmarkDescription.fromJson(Map<String, dynamic> json) => _$LandmarkDescriptionFromJson(json);
|
||||
|
||||
@override final String description;
|
||||
final List<String> _tags;
|
||||
@override List<String> get tags {
|
||||
if (_tags is EqualUnmodifiableListView) return _tags;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_tags);
|
||||
}
|
||||
|
||||
|
||||
/// Create a copy of LandmarkDescription
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$LandmarkDescriptionCopyWith<_LandmarkDescription> get copyWith => __$LandmarkDescriptionCopyWithImpl<_LandmarkDescription>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$LandmarkDescriptionToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _LandmarkDescription&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other._tags, _tags));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,description,const DeepCollectionEquality().hash(_tags));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'LandmarkDescription(description: $description, tags: $tags)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$LandmarkDescriptionCopyWith<$Res> implements $LandmarkDescriptionCopyWith<$Res> {
|
||||
factory _$LandmarkDescriptionCopyWith(_LandmarkDescription value, $Res Function(_LandmarkDescription) _then) = __$LandmarkDescriptionCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String description, List<String> tags
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$LandmarkDescriptionCopyWithImpl<$Res>
|
||||
implements _$LandmarkDescriptionCopyWith<$Res> {
|
||||
__$LandmarkDescriptionCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _LandmarkDescription _self;
|
||||
final $Res Function(_LandmarkDescription) _then;
|
||||
|
||||
/// Create a copy of LandmarkDescription
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? description = null,Object? tags = null,}) {
|
||||
return _then(_LandmarkDescription(
|
||||
description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
|
||||
as String,tags: null == tags ? _self._tags : tags // ignore: cast_nullable_to_non_nullable
|
||||
as List<String>,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
20
frontend/lib/domain/entities/landmark_description.g.dart
Normal file
20
frontend/lib/domain/entities/landmark_description.g.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'landmark_description.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_LandmarkDescription _$LandmarkDescriptionFromJson(Map<String, dynamic> json) =>
|
||||
_LandmarkDescription(
|
||||
description: json['description'] as String,
|
||||
tags: (json['tags'] as List<dynamic>).map((e) => e as String).toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$LandmarkDescriptionToJson(
|
||||
_LandmarkDescription instance,
|
||||
) => <String, dynamic>{
|
||||
'description': instance.description,
|
||||
'tags': instance.tags,
|
||||
};
|
||||
31
frontend/lib/domain/entities/landmark_type.dart
Normal file
31
frontend/lib/domain/entities/landmark_type.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'landmark_type.freezed.dart';
|
||||
part 'landmark_type.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class LandmarkType with _$LandmarkType {
|
||||
const factory LandmarkType({
|
||||
required LandmarkTypeEnum type,
|
||||
}) = _LandmarkType;
|
||||
|
||||
factory LandmarkType.fromJson(Map<String, Object?> json) => _$LandmarkTypeFromJson(json);
|
||||
}
|
||||
|
||||
@JsonEnum(alwaysCreate: true)
|
||||
enum LandmarkTypeEnum {
|
||||
@JsonValue('sightseeing')
|
||||
sightseeing,
|
||||
@JsonValue('nature')
|
||||
nature,
|
||||
@JsonValue('shopping')
|
||||
shopping,
|
||||
@JsonValue('start')
|
||||
start,
|
||||
@JsonValue('finish')
|
||||
finish,
|
||||
}
|
||||
|
||||
extension LandmarkTypeEnumExtension on LandmarkTypeEnum {
|
||||
String get value => _$LandmarkTypeEnumEnumMap[this]!;
|
||||
}
|
||||
277
frontend/lib/domain/entities/landmark_type.freezed.dart
Normal file
277
frontend/lib/domain/entities/landmark_type.freezed.dart
Normal file
@@ -0,0 +1,277 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// 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 'landmark_type.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$LandmarkType {
|
||||
|
||||
LandmarkTypeEnum get type;
|
||||
/// Create a copy of LandmarkType
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$LandmarkTypeCopyWith<LandmarkType> get copyWith => _$LandmarkTypeCopyWithImpl<LandmarkType>(this as LandmarkType, _$identity);
|
||||
|
||||
/// Serializes this LandmarkType to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is LandmarkType&&(identical(other.type, type) || other.type == type));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,type);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'LandmarkType(type: $type)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $LandmarkTypeCopyWith<$Res> {
|
||||
factory $LandmarkTypeCopyWith(LandmarkType value, $Res Function(LandmarkType) _then) = _$LandmarkTypeCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
LandmarkTypeEnum type
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$LandmarkTypeCopyWithImpl<$Res>
|
||||
implements $LandmarkTypeCopyWith<$Res> {
|
||||
_$LandmarkTypeCopyWithImpl(this._self, this._then);
|
||||
|
||||
final LandmarkType _self;
|
||||
final $Res Function(LandmarkType) _then;
|
||||
|
||||
/// Create a copy of LandmarkType
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? type = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||
as LandmarkTypeEnum,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [LandmarkType].
|
||||
extension LandmarkTypePatterns on LandmarkType {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _LandmarkType value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _LandmarkType() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _LandmarkType value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _LandmarkType():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _LandmarkType value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _LandmarkType() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( LandmarkTypeEnum type)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _LandmarkType() when $default != null:
|
||||
return $default(_that.type);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( LandmarkTypeEnum type) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _LandmarkType():
|
||||
return $default(_that.type);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( LandmarkTypeEnum type)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _LandmarkType() when $default != null:
|
||||
return $default(_that.type);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _LandmarkType implements LandmarkType {
|
||||
const _LandmarkType({required this.type});
|
||||
factory _LandmarkType.fromJson(Map<String, dynamic> json) => _$LandmarkTypeFromJson(json);
|
||||
|
||||
@override final LandmarkTypeEnum type;
|
||||
|
||||
/// Create a copy of LandmarkType
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$LandmarkTypeCopyWith<_LandmarkType> get copyWith => __$LandmarkTypeCopyWithImpl<_LandmarkType>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$LandmarkTypeToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _LandmarkType&&(identical(other.type, type) || other.type == type));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,type);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'LandmarkType(type: $type)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$LandmarkTypeCopyWith<$Res> implements $LandmarkTypeCopyWith<$Res> {
|
||||
factory _$LandmarkTypeCopyWith(_LandmarkType value, $Res Function(_LandmarkType) _then) = __$LandmarkTypeCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
LandmarkTypeEnum type
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$LandmarkTypeCopyWithImpl<$Res>
|
||||
implements _$LandmarkTypeCopyWith<$Res> {
|
||||
__$LandmarkTypeCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _LandmarkType _self;
|
||||
final $Res Function(_LandmarkType) _then;
|
||||
|
||||
/// Create a copy of LandmarkType
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? type = null,}) {
|
||||
return _then(_LandmarkType(
|
||||
type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||
as LandmarkTypeEnum,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
21
frontend/lib/domain/entities/landmark_type.g.dart
Normal file
21
frontend/lib/domain/entities/landmark_type.g.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'landmark_type.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_LandmarkType _$LandmarkTypeFromJson(Map<String, dynamic> json) =>
|
||||
_LandmarkType(type: $enumDecode(_$LandmarkTypeEnumEnumMap, json['type']));
|
||||
|
||||
Map<String, dynamic> _$LandmarkTypeToJson(_LandmarkType instance) =>
|
||||
<String, dynamic>{'type': _$LandmarkTypeEnumEnumMap[instance.type]!};
|
||||
|
||||
const _$LandmarkTypeEnumEnumMap = {
|
||||
LandmarkTypeEnum.sightseeing: 'sightseeing',
|
||||
LandmarkTypeEnum.nature: 'nature',
|
||||
LandmarkTypeEnum.shopping: 'shopping',
|
||||
LandmarkTypeEnum.start: 'start',
|
||||
LandmarkTypeEnum.finish: 'finish',
|
||||
};
|
||||
25
frontend/lib/domain/entities/preferences.dart
Normal file
25
frontend/lib/domain/entities/preferences.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
part 'preferences.freezed.dart';
|
||||
part 'preferences.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class Preferences with _$Preferences {
|
||||
const factory Preferences({
|
||||
/// Scores keyed by preference type (e.g. 'sightseeing', 'shopping', 'nature')
|
||||
required Map<String, int> scores,
|
||||
|
||||
/// Maximum trip duration in minutes
|
||||
required int maxTimeMinutes,
|
||||
|
||||
/// Required start location [lat, lon]
|
||||
required List<double> startLocation,
|
||||
|
||||
/// Optional end location
|
||||
List<double>? endLocation,
|
||||
|
||||
/// Optional detour tolerance in minutes
|
||||
int? detourToleranceMinutes,
|
||||
}) = _Preferences;
|
||||
|
||||
factory Preferences.fromJson(Map<String, Object?> json) => _$PreferencesFromJson(json);
|
||||
}
|
||||
322
frontend/lib/domain/entities/preferences.freezed.dart
Normal file
322
frontend/lib/domain/entities/preferences.freezed.dart
Normal file
@@ -0,0 +1,322 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// 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 'preferences.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$Preferences {
|
||||
|
||||
/// Scores keyed by preference type (e.g. 'sightseeing', 'shopping', 'nature')
|
||||
Map<String, int> get scores;/// Maximum trip duration in minutes
|
||||
int get maxTimeMinutes;/// Required start location [lat, lon]
|
||||
List<double> get startLocation;/// Optional end location
|
||||
List<double>? get endLocation;/// Optional detour tolerance in minutes
|
||||
int? get detourToleranceMinutes;
|
||||
/// Create a copy of Preferences
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$PreferencesCopyWith<Preferences> get copyWith => _$PreferencesCopyWithImpl<Preferences>(this as Preferences, _$identity);
|
||||
|
||||
/// Serializes this Preferences to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is Preferences&&const DeepCollectionEquality().equals(other.scores, scores)&&(identical(other.maxTimeMinutes, maxTimeMinutes) || other.maxTimeMinutes == maxTimeMinutes)&&const DeepCollectionEquality().equals(other.startLocation, startLocation)&&const DeepCollectionEquality().equals(other.endLocation, endLocation)&&(identical(other.detourToleranceMinutes, detourToleranceMinutes) || other.detourToleranceMinutes == detourToleranceMinutes));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(scores),maxTimeMinutes,const DeepCollectionEquality().hash(startLocation),const DeepCollectionEquality().hash(endLocation),detourToleranceMinutes);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Preferences(scores: $scores, maxTimeMinutes: $maxTimeMinutes, startLocation: $startLocation, endLocation: $endLocation, detourToleranceMinutes: $detourToleranceMinutes)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $PreferencesCopyWith<$Res> {
|
||||
factory $PreferencesCopyWith(Preferences value, $Res Function(Preferences) _then) = _$PreferencesCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
Map<String, int> scores, int maxTimeMinutes, List<double> startLocation, List<double>? endLocation, int? detourToleranceMinutes
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$PreferencesCopyWithImpl<$Res>
|
||||
implements $PreferencesCopyWith<$Res> {
|
||||
_$PreferencesCopyWithImpl(this._self, this._then);
|
||||
|
||||
final Preferences _self;
|
||||
final $Res Function(Preferences) _then;
|
||||
|
||||
/// Create a copy of Preferences
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? scores = null,Object? maxTimeMinutes = null,Object? startLocation = null,Object? endLocation = freezed,Object? detourToleranceMinutes = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
scores: null == scores ? _self.scores : scores // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, int>,maxTimeMinutes: null == maxTimeMinutes ? _self.maxTimeMinutes : maxTimeMinutes // ignore: cast_nullable_to_non_nullable
|
||||
as int,startLocation: null == startLocation ? _self.startLocation : startLocation // ignore: cast_nullable_to_non_nullable
|
||||
as List<double>,endLocation: freezed == endLocation ? _self.endLocation : endLocation // ignore: cast_nullable_to_non_nullable
|
||||
as List<double>?,detourToleranceMinutes: freezed == detourToleranceMinutes ? _self.detourToleranceMinutes : detourToleranceMinutes // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [Preferences].
|
||||
extension PreferencesPatterns on Preferences {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _Preferences value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _Preferences() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _Preferences value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _Preferences():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _Preferences value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _Preferences() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( Map<String, int> scores, int maxTimeMinutes, List<double> startLocation, List<double>? endLocation, int? detourToleranceMinutes)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _Preferences() when $default != null:
|
||||
return $default(_that.scores,_that.maxTimeMinutes,_that.startLocation,_that.endLocation,_that.detourToleranceMinutes);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( Map<String, int> scores, int maxTimeMinutes, List<double> startLocation, List<double>? endLocation, int? detourToleranceMinutes) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _Preferences():
|
||||
return $default(_that.scores,_that.maxTimeMinutes,_that.startLocation,_that.endLocation,_that.detourToleranceMinutes);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( Map<String, int> scores, int maxTimeMinutes, List<double> startLocation, List<double>? endLocation, int? detourToleranceMinutes)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _Preferences() when $default != null:
|
||||
return $default(_that.scores,_that.maxTimeMinutes,_that.startLocation,_that.endLocation,_that.detourToleranceMinutes);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _Preferences implements Preferences {
|
||||
const _Preferences({required final Map<String, int> scores, required this.maxTimeMinutes, required final List<double> startLocation, final List<double>? endLocation, this.detourToleranceMinutes}): _scores = scores,_startLocation = startLocation,_endLocation = endLocation;
|
||||
factory _Preferences.fromJson(Map<String, dynamic> json) => _$PreferencesFromJson(json);
|
||||
|
||||
/// Scores keyed by preference type (e.g. 'sightseeing', 'shopping', 'nature')
|
||||
final Map<String, int> _scores;
|
||||
/// Scores keyed by preference type (e.g. 'sightseeing', 'shopping', 'nature')
|
||||
@override Map<String, int> get scores {
|
||||
if (_scores is EqualUnmodifiableMapView) return _scores;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableMapView(_scores);
|
||||
}
|
||||
|
||||
/// Maximum trip duration in minutes
|
||||
@override final int maxTimeMinutes;
|
||||
/// Required start location [lat, lon]
|
||||
final List<double> _startLocation;
|
||||
/// Required start location [lat, lon]
|
||||
@override List<double> get startLocation {
|
||||
if (_startLocation is EqualUnmodifiableListView) return _startLocation;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_startLocation);
|
||||
}
|
||||
|
||||
/// Optional end location
|
||||
final List<double>? _endLocation;
|
||||
/// Optional end location
|
||||
@override List<double>? get endLocation {
|
||||
final value = _endLocation;
|
||||
if (value == null) return null;
|
||||
if (_endLocation is EqualUnmodifiableListView) return _endLocation;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(value);
|
||||
}
|
||||
|
||||
/// Optional detour tolerance in minutes
|
||||
@override final int? detourToleranceMinutes;
|
||||
|
||||
/// Create a copy of Preferences
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$PreferencesCopyWith<_Preferences> get copyWith => __$PreferencesCopyWithImpl<_Preferences>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$PreferencesToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _Preferences&&const DeepCollectionEquality().equals(other._scores, _scores)&&(identical(other.maxTimeMinutes, maxTimeMinutes) || other.maxTimeMinutes == maxTimeMinutes)&&const DeepCollectionEquality().equals(other._startLocation, _startLocation)&&const DeepCollectionEquality().equals(other._endLocation, _endLocation)&&(identical(other.detourToleranceMinutes, detourToleranceMinutes) || other.detourToleranceMinutes == detourToleranceMinutes));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_scores),maxTimeMinutes,const DeepCollectionEquality().hash(_startLocation),const DeepCollectionEquality().hash(_endLocation),detourToleranceMinutes);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Preferences(scores: $scores, maxTimeMinutes: $maxTimeMinutes, startLocation: $startLocation, endLocation: $endLocation, detourToleranceMinutes: $detourToleranceMinutes)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$PreferencesCopyWith<$Res> implements $PreferencesCopyWith<$Res> {
|
||||
factory _$PreferencesCopyWith(_Preferences value, $Res Function(_Preferences) _then) = __$PreferencesCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
Map<String, int> scores, int maxTimeMinutes, List<double> startLocation, List<double>? endLocation, int? detourToleranceMinutes
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$PreferencesCopyWithImpl<$Res>
|
||||
implements _$PreferencesCopyWith<$Res> {
|
||||
__$PreferencesCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _Preferences _self;
|
||||
final $Res Function(_Preferences) _then;
|
||||
|
||||
/// Create a copy of Preferences
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? scores = null,Object? maxTimeMinutes = null,Object? startLocation = null,Object? endLocation = freezed,Object? detourToleranceMinutes = freezed,}) {
|
||||
return _then(_Preferences(
|
||||
scores: null == scores ? _self._scores : scores // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, int>,maxTimeMinutes: null == maxTimeMinutes ? _self.maxTimeMinutes : maxTimeMinutes // ignore: cast_nullable_to_non_nullable
|
||||
as int,startLocation: null == startLocation ? _self._startLocation : startLocation // ignore: cast_nullable_to_non_nullable
|
||||
as List<double>,endLocation: freezed == endLocation ? _self._endLocation : endLocation // ignore: cast_nullable_to_non_nullable
|
||||
as List<double>?,detourToleranceMinutes: freezed == detourToleranceMinutes ? _self.detourToleranceMinutes : detourToleranceMinutes // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
28
frontend/lib/domain/entities/preferences.g.dart
Normal file
28
frontend/lib/domain/entities/preferences.g.dart
Normal file
@@ -0,0 +1,28 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'preferences.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_Preferences _$PreferencesFromJson(Map<String, dynamic> json) => _Preferences(
|
||||
scores: Map<String, int>.from(json['scores'] as Map),
|
||||
maxTimeMinutes: (json['maxTimeMinutes'] as num).toInt(),
|
||||
startLocation: (json['startLocation'] as List<dynamic>)
|
||||
.map((e) => (e as num).toDouble())
|
||||
.toList(),
|
||||
endLocation: (json['endLocation'] as List<dynamic>?)
|
||||
?.map((e) => (e as num).toDouble())
|
||||
.toList(),
|
||||
detourToleranceMinutes: (json['detourToleranceMinutes'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PreferencesToJson(_Preferences instance) =>
|
||||
<String, dynamic>{
|
||||
'scores': instance.scores,
|
||||
'maxTimeMinutes': instance.maxTimeMinutes,
|
||||
'startLocation': instance.startLocation,
|
||||
'endLocation': instance.endLocation,
|
||||
'detourToleranceMinutes': instance.detourToleranceMinutes,
|
||||
};
|
||||
24
frontend/lib/domain/entities/trip.dart
Normal file
24
frontend/lib/domain/entities/trip.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import 'package:anyway/domain/entities/landmark.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'trip.freezed.dart';
|
||||
part 'trip.g.dart';
|
||||
|
||||
|
||||
@unfreezed
|
||||
abstract class Trip with _$Trip {
|
||||
|
||||
factory Trip({
|
||||
required String uuid,
|
||||
// Duration totalTime,
|
||||
|
||||
/// total time in minutes
|
||||
int? totalTimeMinutes,
|
||||
|
||||
/// ordered list of landmarks in this trip
|
||||
required List<Landmark> landmarks,
|
||||
}) = _Trip;
|
||||
|
||||
factory Trip.fromJson(Map<String, Object?> json) => _$TripFromJson(json);
|
||||
|
||||
}
|
||||
278
frontend/lib/domain/entities/trip.freezed.dart
Normal file
278
frontend/lib/domain/entities/trip.freezed.dart
Normal file
@@ -0,0 +1,278 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// 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 'trip.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$Trip {
|
||||
|
||||
String get uuid; set uuid(String value);// Duration totalTime,
|
||||
/// total time in minutes
|
||||
int? get totalTimeMinutes;// Duration totalTime,
|
||||
/// total time in minutes
|
||||
set totalTimeMinutes(int? value);/// ordered list of landmarks in this trip
|
||||
List<Landmark> get landmarks;/// ordered list of landmarks in this trip
|
||||
set landmarks(List<Landmark> value);
|
||||
/// Create a copy of Trip
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$TripCopyWith<Trip> get copyWith => _$TripCopyWithImpl<Trip>(this as Trip, _$identity);
|
||||
|
||||
/// Serializes this Trip to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Trip(uuid: $uuid, totalTimeMinutes: $totalTimeMinutes, landmarks: $landmarks)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $TripCopyWith<$Res> {
|
||||
factory $TripCopyWith(Trip value, $Res Function(Trip) _then) = _$TripCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String uuid, int? totalTimeMinutes, List<Landmark> landmarks
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$TripCopyWithImpl<$Res>
|
||||
implements $TripCopyWith<$Res> {
|
||||
_$TripCopyWithImpl(this._self, this._then);
|
||||
|
||||
final Trip _self;
|
||||
final $Res Function(Trip) _then;
|
||||
|
||||
/// Create a copy of Trip
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? uuid = null,Object? totalTimeMinutes = freezed,Object? landmarks = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
uuid: null == uuid ? _self.uuid : uuid // ignore: cast_nullable_to_non_nullable
|
||||
as String,totalTimeMinutes: freezed == totalTimeMinutes ? _self.totalTimeMinutes : totalTimeMinutes // ignore: cast_nullable_to_non_nullable
|
||||
as int?,landmarks: null == landmarks ? _self.landmarks : landmarks // ignore: cast_nullable_to_non_nullable
|
||||
as List<Landmark>,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [Trip].
|
||||
extension TripPatterns on Trip {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _Trip value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _Trip() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _Trip value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _Trip():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _Trip value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _Trip() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String uuid, int? totalTimeMinutes, List<Landmark> landmarks)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _Trip() when $default != null:
|
||||
return $default(_that.uuid,_that.totalTimeMinutes,_that.landmarks);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String uuid, int? totalTimeMinutes, List<Landmark> landmarks) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _Trip():
|
||||
return $default(_that.uuid,_that.totalTimeMinutes,_that.landmarks);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String uuid, int? totalTimeMinutes, List<Landmark> landmarks)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _Trip() when $default != null:
|
||||
return $default(_that.uuid,_that.totalTimeMinutes,_that.landmarks);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _Trip implements Trip {
|
||||
_Trip({required this.uuid, this.totalTimeMinutes, required this.landmarks});
|
||||
factory _Trip.fromJson(Map<String, dynamic> json) => _$TripFromJson(json);
|
||||
|
||||
@override String uuid;
|
||||
// Duration totalTime,
|
||||
/// total time in minutes
|
||||
@override int? totalTimeMinutes;
|
||||
/// ordered list of landmarks in this trip
|
||||
@override List<Landmark> landmarks;
|
||||
|
||||
/// Create a copy of Trip
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$TripCopyWith<_Trip> get copyWith => __$TripCopyWithImpl<_Trip>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$TripToJson(this, );
|
||||
}
|
||||
|
||||
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Trip(uuid: $uuid, totalTimeMinutes: $totalTimeMinutes, landmarks: $landmarks)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$TripCopyWith<$Res> implements $TripCopyWith<$Res> {
|
||||
factory _$TripCopyWith(_Trip value, $Res Function(_Trip) _then) = __$TripCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String uuid, int? totalTimeMinutes, List<Landmark> landmarks
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$TripCopyWithImpl<$Res>
|
||||
implements _$TripCopyWith<$Res> {
|
||||
__$TripCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _Trip _self;
|
||||
final $Res Function(_Trip) _then;
|
||||
|
||||
/// Create a copy of Trip
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? uuid = null,Object? totalTimeMinutes = freezed,Object? landmarks = null,}) {
|
||||
return _then(_Trip(
|
||||
uuid: null == uuid ? _self.uuid : uuid // ignore: cast_nullable_to_non_nullable
|
||||
as String,totalTimeMinutes: freezed == totalTimeMinutes ? _self.totalTimeMinutes : totalTimeMinutes // ignore: cast_nullable_to_non_nullable
|
||||
as int?,landmarks: null == landmarks ? _self.landmarks : landmarks // ignore: cast_nullable_to_non_nullable
|
||||
as List<Landmark>,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
21
frontend/lib/domain/entities/trip.g.dart
Normal file
21
frontend/lib/domain/entities/trip.g.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'trip.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_Trip _$TripFromJson(Map<String, dynamic> json) => _Trip(
|
||||
uuid: json['uuid'] as String,
|
||||
totalTimeMinutes: (json['totalTimeMinutes'] as num?)?.toInt(),
|
||||
landmarks: (json['landmarks'] as List<dynamic>)
|
||||
.map((e) => Landmark.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TripToJson(_Trip instance) => <String, dynamic>{
|
||||
'uuid': instance.uuid,
|
||||
'totalTimeMinutes': instance.totalTimeMinutes,
|
||||
'landmarks': instance.landmarks,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
abstract class OnboardingRepository {
|
||||
/// Returns true when the user accepted the onboarding agreement
|
||||
Future<bool> isOnboarded();
|
||||
|
||||
/// Sets the onboarding completion flag
|
||||
Future<void> setOnboarded(bool value);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import 'package:anyway/domain/entities/preferences.dart';
|
||||
|
||||
abstract class PreferencesRepository {
|
||||
Future<Preferences> getPreferences();
|
||||
Future<void> savePreferences(Preferences preferences);
|
||||
}
|
||||
19
frontend/lib/domain/repositories/trip_repository.dart
Normal file
19
frontend/lib/domain/repositories/trip_repository.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
import 'package:anyway/domain/entities/landmark.dart';
|
||||
import 'package:anyway/domain/entities/preferences.dart';
|
||||
import 'package:anyway/domain/entities/trip.dart';
|
||||
|
||||
abstract class TripRepository {
|
||||
Future<Trip> getTrip({Preferences? preferences, String? tripUUID, List<Landmark>? landmarks});
|
||||
|
||||
Future<List<Landmark>> searchLandmarks(Preferences preferences);
|
||||
|
||||
// TODO - should these be moved to a separate local repository?
|
||||
// not every TripRepository should have a concept of "all saved trips"
|
||||
Future<List<Trip>> getSavedTrips();
|
||||
|
||||
Future<Trip?> getSavedTrip(String uuid);
|
||||
|
||||
Future<void> saveTrip(Trip trip);
|
||||
|
||||
Future<void> deleteSavedTrip(String uuid);
|
||||
}
|
||||
@@ -1,26 +1,39 @@
|
||||
import 'package:anyway/presentation/pages/start.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:anyway/constants.dart';
|
||||
import 'package:anyway/utils/get_first_page.dart';
|
||||
import 'package:anyway/utils/load_trips.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:anyway/core/constants.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
|
||||
void main() => runApp(const App());
|
||||
/// The app entry point.
|
||||
/// Initializes persistence, sets up dependency injection via ProviderScope,
|
||||
/// and determines which screen (login or main app) to show based on auth state.
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Some global variables
|
||||
final GlobalKey<ScaffoldMessengerState> rootScaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
final SavedTrips savedTrips = SavedTrips();
|
||||
// the list of saved trips is then populated implicitly by getFirstPage()
|
||||
// initialize local persistence (shared preferences)
|
||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// the app wrapped in ProviderScope
|
||||
runApp(const ProviderScope(child: MyApp()));
|
||||
}
|
||||
|
||||
class MyApp extends ConsumerWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
class App extends StatelessWidget {
|
||||
const App({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => MaterialApp(
|
||||
title: APP_NAME,
|
||||
home: getFirstPage(),
|
||||
theme: APP_THEME,
|
||||
scaffoldMessengerKey: rootScaffoldMessengerKey
|
||||
);
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
|
||||
return MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: APP_NAME,
|
||||
theme: APP_THEME,
|
||||
scaffoldMessengerKey: rootScaffoldMessengerKey,
|
||||
home: const StartPage()
|
||||
|
||||
// TODO - set up routing
|
||||
// onGenerateRoute: AppRouter.onGenerateRoute,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import 'package:anyway/pages/current_trip.dart';
|
||||
import 'package:anyway/utils/load_trips.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:anyway/structs/trip.dart';
|
||||
|
||||
|
||||
class TripsOverview extends StatefulWidget {
|
||||
final SavedTrips trips;
|
||||
const TripsOverview({
|
||||
super.key,
|
||||
required this.trips,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TripsOverview> createState() => _TripsOverviewState();
|
||||
}
|
||||
|
||||
class _TripsOverviewState extends State<TripsOverview> {
|
||||
Widget listBuild (BuildContext context, SavedTrips trips) {
|
||||
List<Widget> children;
|
||||
List<Trip> items = trips.trips;
|
||||
children = List<Widget>.generate(items.length, (index) {
|
||||
Trip trip = items[index];
|
||||
return ListTile(
|
||||
title: FutureBuilder(
|
||||
future: trip.cityName,
|
||||
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return Text("Trip to ${snapshot.data}");
|
||||
} else if (snapshot.hasError) {
|
||||
return Text("Error: ${snapshot.error}");
|
||||
} else {
|
||||
return const Text("Trip to ...");
|
||||
}
|
||||
},
|
||||
),
|
||||
leading: Icon(Icons.pin_drop),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => TripPage(trip: trip)
|
||||
)
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return ListView(
|
||||
children: children,
|
||||
padding: const EdgeInsets.only(top: 0),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: widget.trips,
|
||||
builder: (BuildContext context, Widget? child) {
|
||||
return listBuild(context, widget.trips);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,13 +26,13 @@ ThemeData APP_THEME = ThemeData(
|
||||
cardColor: Colors.white,
|
||||
useMaterial3: true,
|
||||
|
||||
colorScheme: ColorScheme.light(
|
||||
colorScheme: const ColorScheme.light(
|
||||
primary: PRIMARY_COLOR,
|
||||
secondary: GRADIENT_END,
|
||||
surface: Colors.white,
|
||||
error: Colors.red,
|
||||
onPrimary: Colors.white,
|
||||
onSecondary: const Color.fromARGB(255, 30, 22, 22),
|
||||
onSecondary: Color.fromARGB(255, 30, 22, 22),
|
||||
onSurface: Colors.black,
|
||||
onError: Colors.white,
|
||||
brightness: Brightness.light,
|
||||
@@ -64,12 +64,6 @@ ThemeData APP_THEME = ThemeData(
|
||||
),
|
||||
|
||||
|
||||
cardTheme: const CardTheme(
|
||||
shadowColor: Colors.grey,
|
||||
elevation: 2,
|
||||
margin: EdgeInsets.all(10),
|
||||
),
|
||||
|
||||
sliderTheme: const SliderThemeData(
|
||||
trackHeight: 15,
|
||||
inactiveTrackColor: Colors.grey,
|
||||
@@ -83,4 +77,4 @@ const Gradient APP_GRADIENT = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [GRADIENT_START, GRADIENT_END],
|
||||
);
|
||||
);
|
||||
@@ -20,7 +20,7 @@ class _CurrentTripErrorMessageState extends State<CurrentTripErrorMessage> {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
const Text(
|
||||
"😢",
|
||||
style: TextStyle(
|
||||
fontSize: 40,
|
||||
@@ -8,7 +8,7 @@ import 'package:anyway/structs/trip.dart';
|
||||
class CurrentTripGreeter extends StatefulWidget {
|
||||
final Trip trip;
|
||||
|
||||
CurrentTripGreeter({
|
||||
const CurrentTripGreeter({
|
||||
super.key,
|
||||
required this.trip,
|
||||
});
|
||||
@@ -47,4 +47,4 @@ class _CurrentTripGreeterState extends State<CurrentTripGreeter> {
|
||||
)
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import 'package:anyway/modules/landmark_card.dart';
|
||||
|
||||
// Returns a list of widgets that represent the landmarks matching the given selector
|
||||
List<Widget> landmarksList(Trip trip, {required bool Function(Landmark) selector}) {
|
||||
|
||||
|
||||
List<Widget> children = [];
|
||||
|
||||
if (trip.landmarks.isEmpty || trip.landmarks.length <= 1 && trip.landmarks.first.type == typeStart ) {
|
||||
@@ -30,10 +30,10 @@ List<Widget> landmarksList(Trip trip, {required bool Function(Landmark) selector
|
||||
Landmark? nextLandmark = landmark.next;
|
||||
while (nextLandmark != null && nextLandmark.visited) {
|
||||
nextLandmark = nextLandmark.next;
|
||||
}
|
||||
}
|
||||
if (nextLandmark != null) {
|
||||
children.add(
|
||||
StepBetweenLandmarks(current: landmark, next: nextLandmark!)
|
||||
StepBetweenLandmarks(current: landmark, next: nextLandmark)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ class _CurrentTripLoadingIndicatorState extends State<CurrentTripLoadingIndicato
|
||||
|
||||
// automatically cycle through the greeter texts
|
||||
class StatusText extends StatefulWidget {
|
||||
const StatusText({Key? key}) : super(key: key);
|
||||
const StatusText({super.key});
|
||||
|
||||
@override
|
||||
_StatusTextState createState() => _StatusTextState();
|
||||
@@ -110,10 +110,10 @@ class AnimatedDotsText extends StatefulWidget {
|
||||
final TextStyle style;
|
||||
|
||||
const AnimatedDotsText({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.baseText,
|
||||
required this.style,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
_AnimatedDotsTextState createState() => _AnimatedDotsTextState();
|
||||
53
frontend/lib/old/modules/current_trip_locations.dart
Normal file
53
frontend/lib/old/modules/current_trip_locations.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:auto_size_text/auto_size_text.dart';
|
||||
|
||||
import 'package:anyway/structs/trip.dart';
|
||||
|
||||
|
||||
List<Map<String, dynamic>> locationActions = [
|
||||
{'name': 'Toilet', 'action': () {}},
|
||||
{'name': 'Food', 'action': () {}},
|
||||
{'name': 'Surrounding landmarks', 'action': () {}},
|
||||
|
||||
];
|
||||
|
||||
class CurrentTripLocations extends StatefulWidget {
|
||||
final Trip? trip;
|
||||
|
||||
const CurrentTripLocations({super.key, this.trip});
|
||||
|
||||
@override
|
||||
State<CurrentTripLocations> createState() => _CurrentTripLocationsState();
|
||||
}
|
||||
|
||||
class _CurrentTripLocationsState extends State<CurrentTripLocations> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// A horizontally scrolling list of buttons with predefined actions
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 10.0),
|
||||
child: Row(
|
||||
children: [
|
||||
if (widget.trip != null)
|
||||
for (Map action in locationActions)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3.0),
|
||||
child: ElevatedButton(
|
||||
onPressed: action['action'],
|
||||
child: AutoSizeText(
|
||||
action['name'],
|
||||
maxLines: 1,
|
||||
minFontSize: 8,
|
||||
maxFontSize: 16,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import 'package:anyway/modules/landmark_map_marker.dart';
|
||||
class CurrentTripMap extends StatefulWidget {
|
||||
final Trip? trip;
|
||||
|
||||
CurrentTripMap({this.trip});
|
||||
const CurrentTripMap({super.key, this.trip});
|
||||
|
||||
@override
|
||||
State<CurrentTripMap> createState() => _CurrentTripMapState();
|
||||
@@ -22,7 +22,7 @@ class CurrentTripMap extends StatefulWidget {
|
||||
class _CurrentTripMapState extends State<CurrentTripMap> {
|
||||
late GoogleMapController mapController;
|
||||
|
||||
CameraPosition _cameraPosition = CameraPosition(
|
||||
final CameraPosition _cameraPosition = const CameraPosition(
|
||||
target: LatLng(48.8566, 2.3522),
|
||||
zoom: 11.0,
|
||||
);
|
||||
@@ -41,7 +41,7 @@ class _CurrentTripMapState extends State<CurrentTripMap> {
|
||||
void dispose() {
|
||||
widget.trip?.removeListener(setMapMarkers);
|
||||
widget.trip?.removeListener(setMapRoute);
|
||||
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
31
frontend/lib/old/modules/current_trip_overview.dart
Normal file
31
frontend/lib/old/modules/current_trip_overview.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'package:anyway/structs/trip.dart';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:anyway/modules/current_trip_map.dart';
|
||||
import 'package:anyway/modules/current_trip_locations.dart';
|
||||
|
||||
|
||||
class CurrentTripOverview extends StatefulWidget {
|
||||
final Trip? trip;
|
||||
|
||||
const CurrentTripOverview({super.key, this.trip});
|
||||
|
||||
|
||||
@override
|
||||
State<CurrentTripOverview> createState() => _CurrentTripOverviewState();
|
||||
}
|
||||
|
||||
class _CurrentTripOverviewState extends State<CurrentTripOverview> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// The background map has a horizontally scrolling list of rounded buttons overlaid
|
||||
return Stack(
|
||||
alignment: Alignment.topLeft,
|
||||
children: [
|
||||
CurrentTripMap(trip: widget.trip),
|
||||
CurrentTripLocations(trip: widget.trip),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ class _CurrentTripPanelState extends State<CurrentTripPanel> {
|
||||
// this way the greeter will be centered when the panel is collapsed
|
||||
// note that we need to account for the padding above
|
||||
height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT - 10,
|
||||
child: Center(child:
|
||||
child: Center(child:
|
||||
AutoSizeText(
|
||||
maxLines: 1,
|
||||
'Error',
|
||||
@@ -81,7 +81,7 @@ class _CurrentTripPanelState extends State<CurrentTripPanel> {
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.all(10),
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
@@ -94,9 +94,6 @@ class _CurrentTripPanelState extends State<CurrentTripPanel> {
|
||||
ExpansionTile(
|
||||
leading: const Icon(Icons.location_on),
|
||||
title: const Text('Visited Landmarks (tap to expand)'),
|
||||
children: [
|
||||
...landmarksList(widget.trip, selector: (Landmark landmark) => landmark.visited),
|
||||
],
|
||||
visualDensity: VisualDensity.compact,
|
||||
collapsedShape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
@@ -104,12 +101,15 @@ class _CurrentTripPanelState extends State<CurrentTripPanel> {
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
children: [
|
||||
...landmarksList(widget.trip, selector: (Landmark landmark) => landmark.visited),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
|
||||
const Padding(padding: EdgeInsets.only(top: 10)),
|
||||
|
||||
@@ -20,14 +20,14 @@ class _saveButtonState extends State<saveButton> {
|
||||
onPressed: () async {
|
||||
savedTrips.addTrip(widget.trip);
|
||||
rootScaffoldMessengerKey.currentState!.showSnackBar(
|
||||
SnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Trip saved'),
|
||||
duration: Duration(seconds: 2),
|
||||
dismissDirection: DismissDirection.horizontal
|
||||
)
|
||||
);
|
||||
},
|
||||
child: SizedBox(
|
||||
child: const SizedBox(
|
||||
width: 100,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -13,12 +13,12 @@ import 'package:anyway/structs/landmark.dart';
|
||||
class LandmarkCard extends StatefulWidget {
|
||||
final Landmark landmark;
|
||||
final Trip parentTrip;
|
||||
|
||||
LandmarkCard(
|
||||
|
||||
const LandmarkCard(
|
||||
this.landmark,
|
||||
this.parentTrip,
|
||||
);
|
||||
|
||||
|
||||
@override
|
||||
_LandmarkCardState createState() => _LandmarkCardState();
|
||||
}
|
||||
@@ -26,7 +26,7 @@ class LandmarkCard extends StatefulWidget {
|
||||
|
||||
class _LandmarkCardState extends State<LandmarkCard> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
// express the max height in terms text lines
|
||||
@@ -38,7 +38,7 @@ class _LandmarkCardState extends State<LandmarkCard> {
|
||||
),
|
||||
elevation: 5,
|
||||
clipBehavior: Clip.antiAliasWithSaveLayer,
|
||||
|
||||
|
||||
// if the image is available, display it on the left side of the card, otherwise only display the text
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -66,11 +66,11 @@ class _LandmarkCardState extends State<LandmarkCard> {
|
||||
color: PRIMARY_COLOR,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(5),
|
||||
padding: const EdgeInsets.all(5),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.timer_outlined, size: 16),
|
||||
const Icon(Icons.timer_outlined, size: 16),
|
||||
Text("${widget.landmark.duration?.inMinutes} minutes"),
|
||||
],
|
||||
)
|
||||
@@ -97,7 +97,7 @@ class _LandmarkCardState extends State<LandmarkCard> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 2,
|
||||
),
|
||||
|
||||
|
||||
if (widget.landmark.nameEN != null)
|
||||
Text(
|
||||
widget.landmark.nameEN!,
|
||||
@@ -114,7 +114,7 @@ class _LandmarkCardState extends State<LandmarkCard> {
|
||||
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: EdgeInsets.only(left: 5, right: 5, bottom: 10),
|
||||
padding: const EdgeInsets.only(left: 5, right: 5, bottom: 10),
|
||||
// the scroll view should be flush once the buttons are scrolled to the left
|
||||
// but initially there should be some padding
|
||||
child: Wrap(
|
||||
@@ -124,7 +124,7 @@ class _LandmarkCardState extends State<LandmarkCard> {
|
||||
doneToggleButton(),
|
||||
if (widget.landmark.websiteURL != null)
|
||||
websiteButton(),
|
||||
|
||||
|
||||
optionsButton()
|
||||
],
|
||||
),
|
||||
@@ -181,7 +181,7 @@ class _LandmarkCardState extends State<LandmarkCard> {
|
||||
title: const Text('Favorite'),
|
||||
onTap: () async {
|
||||
rootScaffoldMessengerKey.currentState!.showSnackBar(
|
||||
SnackBar(content: Text("Not implemented yet"))
|
||||
const SnackBar(content: Text("Not implemented yet"))
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -193,7 +193,7 @@ class _LandmarkCardState extends State<LandmarkCard> {
|
||||
|
||||
|
||||
Widget imagePlaceholder (Landmark landmark) => Expanded(
|
||||
child:
|
||||
child:
|
||||
Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
@@ -7,7 +7,7 @@ class ThemedMarker extends StatelessWidget {
|
||||
final Landmark landmark;
|
||||
final int position;
|
||||
|
||||
ThemedMarker({
|
||||
const ThemedMarker({
|
||||
super.key,
|
||||
required this.landmark,
|
||||
required this.position
|
||||
@@ -24,12 +24,12 @@ class ThemedMarker extends StatelessWidget {
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(5),
|
||||
padding: const EdgeInsets.all(5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text('$position', style: TextStyle(color: Colors.black, fontSize: 25)),
|
||||
child: Text('$position', style: const TextStyle(color: Colors.black, fontSize: 25)),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -40,7 +40,7 @@ class ThemedMarker extends StatelessWidget {
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: landmark.visited ? LinearGradient(colors: [Colors.grey, Colors.white]) : APP_GRADIENT,
|
||||
gradient: landmark.visited ? const LinearGradient(colors: [Colors.grey, Colors.white]) : APP_GRADIENT,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.black, width: 5),
|
||||
),
|
||||
@@ -54,4 +54,4 @@ class ThemedMarker extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ class NewTripButton extends StatefulWidget {
|
||||
final Trip trip;
|
||||
final UserPreferences preferences;
|
||||
|
||||
const NewTripButton({
|
||||
const NewTripButton({super.key,
|
||||
required this.trip,
|
||||
required this.preferences,
|
||||
});
|
||||
@@ -35,8 +35,8 @@ class _NewTripButtonState extends State<NewTripButton> {
|
||||
return FloatingActionButton.extended(
|
||||
onPressed: onPressed,
|
||||
icon: const Icon(Icons.directions),
|
||||
label: AutoSizeText('Start planning!'),
|
||||
);
|
||||
label: const AutoSizeText('Start planning!'),
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ const Map<String, List> debugLocations = {
|
||||
class NewTripLocationSearch extends StatefulWidget {
|
||||
Future<SharedPreferences> prefs = SharedPreferences.getInstance();
|
||||
Trip trip;
|
||||
|
||||
|
||||
NewTripLocationSearch(
|
||||
this.trip,
|
||||
);
|
||||
@@ -71,13 +71,13 @@ class _NewTripLocationSearchState extends State<NewTripLocationSearch> {
|
||||
hintText: 'Enter a city name or long press on the map.',
|
||||
onSubmitted: setTripLocation,
|
||||
controller: _controller,
|
||||
leading: Icon(Icons.search),
|
||||
leading: const Icon(Icons.search),
|
||||
trailing: [
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
setTripLocation(_controller.text);
|
||||
},
|
||||
child: Text('Search'),
|
||||
child: const Text('Search'),
|
||||
)
|
||||
]
|
||||
);
|
||||
@@ -97,7 +97,7 @@ class _NewTripLocationSearchState extends State<NewTripLocationSearch> {
|
||||
)
|
||||
);
|
||||
},
|
||||
child: Text('Use current location'),
|
||||
child: const Text('Use current location'),
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -106,4 +106,4 @@ class _NewTripMapState extends State<NewTripMap> {
|
||||
myLocationEnabled: useLocation,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import 'package:flutter/material.dart';
|
||||
class NewTripOptionsButton extends StatefulWidget {
|
||||
final Trip trip;
|
||||
|
||||
const NewTripOptionsButton({required this.trip});
|
||||
const NewTripOptionsButton({super.key, required this.trip});
|
||||
|
||||
@override
|
||||
State<NewTripOptionsButton> createState() => _NewTripOptionsButtonState();
|
||||
@@ -33,7 +33,7 @@ class _NewTripOptionsButtonState extends State<NewTripOptionsButton> {
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const AutoSizeText('Set preferences')
|
||||
);
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -13,8 +13,8 @@ class OnboardingAgreementCard extends StatefulWidget {
|
||||
final ValueChanged<bool> onAgreementChanged;
|
||||
|
||||
|
||||
OnboardingAgreementCard({
|
||||
super.key,
|
||||
const OnboardingAgreementCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.imagePath,
|
||||
@@ -30,12 +30,12 @@ class _OnboardingAgreementCardState extends State<OnboardingAgreementCard> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.all(20),
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
OnboardingCard(title: widget.title, description: widget.description, imagePath: widget.imagePath),
|
||||
Padding(padding: EdgeInsets.only(top: 20)),
|
||||
const Padding(padding: EdgeInsets.only(top: 20)),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
@@ -65,7 +65,7 @@ class _OnboardingAgreementCardState extends State<OnboardingAgreementCard> {
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
|
||||
// The text of the agreement
|
||||
Text(
|
||||
"I agree to the ",
|
||||
@@ -73,7 +73,7 @@ class _OnboardingAgreementCardState extends State<OnboardingAgreementCard> {
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// The clickable text of the agreement that shows the agreement text
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
@@ -91,9 +91,9 @@ class _OnboardingAgreementCardState extends State<OnboardingAgreementCard> {
|
||||
data: snapshot.data.toString(),
|
||||
);
|
||||
} else {
|
||||
return CircularProgressIndicator();
|
||||
return const CircularProgressIndicator();
|
||||
}
|
||||
|
||||
|
||||
},
|
||||
)
|
||||
);
|
||||
@@ -6,7 +6,7 @@ class OnboardingCard extends StatelessWidget {
|
||||
final String description;
|
||||
final String imagePath;
|
||||
|
||||
const OnboardingCard({
|
||||
const OnboardingCard({super.key,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.imagePath,
|
||||
@@ -14,9 +14,9 @@ class OnboardingCard extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.all(20),
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
@@ -26,12 +26,12 @@ class OnboardingCard extends StatelessWidget {
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
Padding(padding: EdgeInsets.only(top: 20)),
|
||||
const Padding(padding: EdgeInsets.only(top: 20)),
|
||||
SvgPicture.asset(
|
||||
imagePath,
|
||||
height: 200,
|
||||
),
|
||||
Padding(padding: EdgeInsets.only(top: 20)),
|
||||
const Padding(padding: EdgeInsets.only(top: 20)),
|
||||
Text(
|
||||
description,
|
||||
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||
94
frontend/lib/old/modules/trips_saved_list.dart
Normal file
94
frontend/lib/old/modules/trips_saved_list.dart
Normal file
@@ -0,0 +1,94 @@
|
||||
import 'package:anyway/pages/current_trip.dart';
|
||||
import 'package:anyway/utils/load_trips.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:anyway/structs/trip.dart';
|
||||
|
||||
|
||||
class TripsOverview extends StatefulWidget {
|
||||
final SavedTrips trips;
|
||||
const TripsOverview({
|
||||
super.key,
|
||||
required this.trips,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TripsOverview> createState() => _TripsOverviewState();
|
||||
}
|
||||
|
||||
class _TripsOverviewState extends State<TripsOverview> {
|
||||
Widget tripListItemBuilder(BuildContext context, int index) {
|
||||
Trip trip = widget.trips.trips[index];
|
||||
return ListTile(
|
||||
title: FutureBuilder(
|
||||
future: trip.cityName,
|
||||
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return Text("Trip to ${snapshot.data}");
|
||||
} else if (snapshot.hasError) {
|
||||
return Text("Error: ${snapshot.error}");
|
||||
} else {
|
||||
return const Text("Trip to ...");
|
||||
}
|
||||
},
|
||||
),
|
||||
// emoji of the country flag of the trip's country
|
||||
leading: const Icon(Icons.pin_drop),
|
||||
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => TripPage(trip: trip)
|
||||
)
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Widget listBuild (BuildContext context, SavedTrips trips) {
|
||||
// List<Widget> children;
|
||||
// List<Trip> items = trips.trips;
|
||||
// children = List<Widget>.generate(items.length, (index) {
|
||||
// Trip trip = items[index];
|
||||
// return ListTile(
|
||||
// title: FutureBuilder(
|
||||
// future: trip.cityName,
|
||||
// builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
|
||||
// if (snapshot.hasData) {
|
||||
// return Text("Trip to ${snapshot.data}");
|
||||
// } else if (snapshot.hasError) {
|
||||
// return Text("Error: ${snapshot.error}");
|
||||
// } else {
|
||||
// return const Text("Trip to ...");
|
||||
// }
|
||||
// },
|
||||
// ),
|
||||
// leading: const Icon(Icons.pin_drop),
|
||||
// onTap: () {
|
||||
// Navigator.of(context).push(
|
||||
// MaterialPageRoute(
|
||||
// builder: (context) => TripPage(trip: trip)
|
||||
// )
|
||||
// );
|
||||
// },
|
||||
// );
|
||||
// });
|
||||
|
||||
// return ListView(
|
||||
// padding: const EdgeInsets.only(top: 0),
|
||||
// children: children,
|
||||
// );
|
||||
// }
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: widget.trips,
|
||||
builder: (BuildContext context, Widget? child) => ListView.builder(
|
||||
itemCount: widget.trips.trips.length,
|
||||
itemBuilder: tripListItemBuilder,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:sliding_up_panel/sliding_up_panel.dart';
|
||||
|
||||
import 'package:anyway/structs/trip.dart';
|
||||
import 'package:anyway/modules/current_trip_map.dart';
|
||||
import 'package:anyway/modules/current_trip_overview.dart';
|
||||
import 'package:anyway/modules/current_trip_panel.dart';
|
||||
|
||||
final Shader textGradient = APP_GRADIENT.createShader(Rect.fromLTWH(0.0, 0.0, 200.0, 70.0));
|
||||
final Shader textGradient = APP_GRADIENT.createShader(const Rect.fromLTWH(0.0, 0.0, 200.0, 70.0));
|
||||
TextStyle greeterStyle = TextStyle(
|
||||
foreground: Paint()..shader = textGradient,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -18,7 +18,7 @@ TextStyle greeterStyle = TextStyle(
|
||||
class TripPage extends StatefulWidget {
|
||||
final Trip trip;
|
||||
|
||||
TripPage({
|
||||
const TripPage({super.key,
|
||||
required this.trip,
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ class _TripPageState extends State<TripPage> with ScaffoldLayout{
|
||||
panelBuilder: (scrollcontroller) => CurrentTripPanel(controller: scrollcontroller, trip: widget.trip),
|
||||
// using collapsed and panelBuilder seems to show both at the same time, so we include the greeter in the panelBuilder
|
||||
// collapsed: Greeter(trip: widget.trip),
|
||||
body: CurrentTripMap(trip: widget.trip),
|
||||
body: CurrentTripOverview(trip: widget.trip),
|
||||
minHeight: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT,
|
||||
maxHeight: MediaQuery.of(context).size.height * TRIP_PANEL_MAX_HEIGHT,
|
||||
// padding in this context is annoying: it offsets the notion of vertical alignment.
|
||||
@@ -8,7 +8,7 @@ import 'package:anyway/modules/new_trip_map.dart';
|
||||
|
||||
|
||||
class NewTripPage extends StatefulWidget {
|
||||
const NewTripPage({Key? key}) : super(key: key);
|
||||
const NewTripPage({super.key});
|
||||
|
||||
@override
|
||||
_NewTripPageState createState() => _NewTripPageState();
|
||||
@@ -30,14 +30,14 @@ class _NewTripPageState extends State<NewTripPage> with ScaffoldLayout {
|
||||
children: [
|
||||
NewTripMap(trip),
|
||||
Padding(
|
||||
padding: EdgeInsets.all(15),
|
||||
padding: const EdgeInsets.all(15),
|
||||
child: NewTripLocationSearch(trip),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: NewTripOptionsButton(trip: trip),
|
||||
),
|
||||
title: Text("New Trip"),
|
||||
title: const Text("New Trip"),
|
||||
helpTexts: [
|
||||
"Setting the start location",
|
||||
"To set the starting point, type a city name in the search bar. You can also navigate the map like you're used to and long press anywhere to set a starting point."
|
||||
@@ -10,7 +10,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
class NewTripPreferencesPage extends StatefulWidget {
|
||||
final Trip trip;
|
||||
const NewTripPreferencesPage({required this.trip});
|
||||
const NewTripPreferencesPage({super.key, required this.trip});
|
||||
|
||||
@override
|
||||
_NewTripPreferencesPageState createState() => _NewTripPreferencesPageState();
|
||||
@@ -34,14 +34,14 @@ class _NewTripPreferencesPageState extends State<NewTripPreferencesPage> with Sc
|
||||
child: Scaffold(
|
||||
body: ListView(
|
||||
children: [
|
||||
Center(
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(left: 10, right: 10, top: 20, bottom: 0),
|
||||
child: Text('Tell us about your ideal trip.', style: TextStyle(fontSize: 18))
|
||||
),
|
||||
),
|
||||
|
||||
Divider(indent: 25, endIndent: 25, height: 50),
|
||||
const Divider(indent: 25, endIndent: 25, height: 50),
|
||||
|
||||
durationPicker(preferences.maxTime),
|
||||
|
||||
@@ -78,7 +78,7 @@ class _NewTripPreferencesPageState extends State<NewTripPreferencesPage> with Sc
|
||||
title: Text(preferences.maxTime.description),
|
||||
subtitle: CupertinoTimerPicker(
|
||||
mode: CupertinoTimerPickerMode.hm,
|
||||
initialTimerDuration: Duration(minutes: 90),
|
||||
initialTimerDuration: const Duration(minutes: 90),
|
||||
minuteInterval: 15,
|
||||
onTimerDurationChanged: (Duration newDuration) {
|
||||
setState(() {
|
||||
@@ -23,7 +23,7 @@ class _NoTripsPageState extends State<NoTripsPage> with ScaffoldLayout {
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Padding(padding: EdgeInsets.only(bottom: 10)),
|
||||
const Padding(padding: EdgeInsets.only(bottom: 10)),
|
||||
Text(
|
||||
"You can start a new trip by clicking the button below",
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
@@ -50,4 +50,4 @@ class _NoTripsPageState extends State<NoTripsPage> with ScaffoldLayout {
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import 'package:anyway/layouts/scaffold.dart';
|
||||
bool debugMode = false;
|
||||
|
||||
class SettingsPage extends StatefulWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
@override
|
||||
_SettingsPageState createState() => _SettingsPageState();
|
||||
}
|
||||
@@ -20,31 +22,31 @@ class _SettingsPageState extends State<SettingsPage> with ScaffoldLayout {
|
||||
Widget build (BuildContext context) => mainScaffold(
|
||||
context,
|
||||
child: ListView(
|
||||
padding: EdgeInsets.all(15),
|
||||
padding: const EdgeInsets.all(15),
|
||||
children: [
|
||||
// First a round, centered image
|
||||
Center(
|
||||
const Center(
|
||||
child: CircleAvatar(
|
||||
radius: 75,
|
||||
child: Icon(Icons.settings, size: 100),
|
||||
)
|
||||
),
|
||||
Center(
|
||||
const Center(
|
||||
child: Text('Global settings', style: TextStyle(fontSize: 24))
|
||||
),
|
||||
|
||||
Divider(indent: 25, endIndent: 25, height: 50),
|
||||
const Divider(indent: 25, endIndent: 25, height: 50),
|
||||
|
||||
darkMode(),
|
||||
setLocationUsage(),
|
||||
setDebugMode(),
|
||||
|
||||
Divider(indent: 25, endIndent: 25, height: 50),
|
||||
const Divider(indent: 25, endIndent: 25, height: 50),
|
||||
|
||||
privacyInfo(),
|
||||
]
|
||||
),
|
||||
title: Text('Settings'),
|
||||
title: const Text('Settings'),
|
||||
helpTexts: [
|
||||
'Settings',
|
||||
'Preferences set in this page are global and will affect the entire application.'
|
||||
@@ -54,9 +56,9 @@ class _SettingsPageState extends State<SettingsPage> with ScaffoldLayout {
|
||||
Widget setDebugMode() {
|
||||
return Row(
|
||||
children: [
|
||||
Text('Debugging: use a custom API URL'),
|
||||
const Text('Debugging: use a custom API URL'),
|
||||
// white space
|
||||
Spacer(),
|
||||
const Spacer(),
|
||||
Switch(
|
||||
value: debugMode,
|
||||
onChanged: (bool? newValue) {
|
||||
@@ -67,7 +69,7 @@ class _SettingsPageState extends State<SettingsPage> with ScaffoldLayout {
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('Debug mode - use a custom API endpoint'),
|
||||
title: const Text('Debug mode - use a custom API endpoint'),
|
||||
content: TextField(
|
||||
controller: TextEditingController(text: API_URL_DEBUG),
|
||||
onChanged: (value) {
|
||||
@@ -78,7 +80,7 @@ class _SettingsPageState extends State<SettingsPage> with ScaffoldLayout {
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: Text('OK'),
|
||||
child: const Text('OK'),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
@@ -98,14 +100,14 @@ class _SettingsPageState extends State<SettingsPage> with ScaffoldLayout {
|
||||
Widget darkMode() {
|
||||
return Row(
|
||||
children: [
|
||||
Text('Dark mode'),
|
||||
Spacer(),
|
||||
const Text('Dark mode'),
|
||||
const Spacer(),
|
||||
Switch(
|
||||
value: Theme.of(context).brightness == Brightness.dark,
|
||||
onChanged: (bool? newValue) {
|
||||
setState(() {
|
||||
rootScaffoldMessengerKey.currentState!.showSnackBar(
|
||||
SnackBar(content: Text('Dark mode is not implemented yet'))
|
||||
const SnackBar(content: Text('Dark mode is not implemented yet'))
|
||||
);
|
||||
// if (newValue!) {
|
||||
// // Dark mode
|
||||
@@ -125,9 +127,9 @@ class _SettingsPageState extends State<SettingsPage> with ScaffoldLayout {
|
||||
Future<SharedPreferences> preferences = SharedPreferences.getInstance();
|
||||
return Row(
|
||||
children: [
|
||||
Text('Use location services'),
|
||||
const Text('Use location services'),
|
||||
// white space
|
||||
Spacer(),
|
||||
const Spacer(),
|
||||
FutureBuilder(
|
||||
future: preferences,
|
||||
builder: (context, snapshot) {
|
||||
@@ -138,7 +140,7 @@ class _SettingsPageState extends State<SettingsPage> with ScaffoldLayout {
|
||||
onChanged: setUseLocation,
|
||||
);
|
||||
} else {
|
||||
return CircularProgressIndicator();
|
||||
return const CircularProgressIndicator();
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -150,12 +152,12 @@ class _SettingsPageState extends State<SettingsPage> with ScaffoldLayout {
|
||||
await Permission.locationWhenInUse
|
||||
.onDeniedCallback(() {
|
||||
rootScaffoldMessengerKey.currentState!.showSnackBar(
|
||||
SnackBar(content: Text('Location services are required for this feature'))
|
||||
const SnackBar(content: Text('Location services are required for this feature'))
|
||||
);
|
||||
})
|
||||
.onGrantedCallback(() {
|
||||
rootScaffoldMessengerKey.currentState!.showSnackBar(
|
||||
SnackBar(content: Text('Location services are now enabled'))
|
||||
const SnackBar(content: Text('Location services are now enabled'))
|
||||
);
|
||||
SharedPreferences.getInstance().then(
|
||||
(SharedPreferences prefs) {
|
||||
@@ -167,9 +169,9 @@ class _SettingsPageState extends State<SettingsPage> with ScaffoldLayout {
|
||||
})
|
||||
.onPermanentlyDeniedCallback(() {
|
||||
rootScaffoldMessengerKey.currentState!.showSnackBar(
|
||||
SnackBar(content: Text('Location services are required for this feature'))
|
||||
const SnackBar(content: Text('Location services are required for this feature'))
|
||||
);
|
||||
})
|
||||
})
|
||||
.request();
|
||||
}
|
||||
|
||||
@@ -177,12 +179,12 @@ class _SettingsPageState extends State<SettingsPage> with ScaffoldLayout {
|
||||
return Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Text('AnyWay does not collect or store any of the data that is submitted via the app. The location of your trip is not stored. The location feature is only used to show your current location on the map.', textAlign: TextAlign.center),
|
||||
Padding(padding: EdgeInsets.only(top: 3)),
|
||||
Text('Our full privacy policy is available under:', textAlign: TextAlign.center),
|
||||
|
||||
const Text('AnyWay does not collect or store any of the data that is submitted via the app. The location of your trip is not stored. The location feature is only used to show your current location on the map.', textAlign: TextAlign.center),
|
||||
const Padding(padding: EdgeInsets.only(top: 3)),
|
||||
const Text('Our full privacy policy is available under:', textAlign: TextAlign.center),
|
||||
|
||||
TextButton.icon(
|
||||
icon: Icon(Icons.info),
|
||||
icon: const Icon(Icons.info),
|
||||
label: Text(PRIVACY_URL),
|
||||
onPressed: () async{
|
||||
await launchUrl(Uri.parse(PRIVACY_URL));
|
||||
@@ -53,7 +53,7 @@ class UserPreferences {
|
||||
value: 30,
|
||||
minVal: 30,
|
||||
maxVal: 720,
|
||||
icon: Icon(Icons.timer),
|
||||
icon: const Icon(Icons.timer),
|
||||
);
|
||||
|
||||
|
||||
@@ -66,4 +66,4 @@ class UserPreferences {
|
||||
"max_time_minute": maxTime.value
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ class Trip with ChangeNotifier {
|
||||
String? errorDescription;
|
||||
|
||||
Future<String> get cityName async {
|
||||
List<double>? location = landmarks.firstOrNull?.location;
|
||||
List<double>? location = landmarks.firstOrNull?.location;
|
||||
if (GeocodingPlatform.instance == null) {
|
||||
return '$location';
|
||||
} else if (location == null) {
|
||||
@@ -48,7 +48,7 @@ class Trip with ChangeNotifier {
|
||||
LinkedList<Landmark>? landmarks
|
||||
// a trip can be created with no landmarks, but the list should be initialized anyway
|
||||
}) : landmarks = landmarks ?? LinkedList<Landmark>();
|
||||
|
||||
|
||||
|
||||
factory Trip.fromJson(Map<String, dynamic> json) {
|
||||
Trip trip = Trip(
|
||||
@@ -82,11 +82,11 @@ class Trip with ChangeNotifier {
|
||||
// removing the landmark means we need to recompute the time between the two adjoined landmarks
|
||||
if (previous != null && next != null) {
|
||||
// previous.next = next happens automatically since we are using a LinkedList
|
||||
this.totalTime -= previous.tripTime ?? Duration.zero;
|
||||
totalTime -= previous.tripTime ?? Duration.zero;
|
||||
previous.tripTime = null;
|
||||
// TODO
|
||||
}
|
||||
this.totalTime -= landmark.tripTime ?? Duration.zero;
|
||||
totalTime -= landmark.tripTime ?? Duration.zero;
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -21,7 +21,7 @@ Dio dio = Dio(
|
||||
validateStatus: (status) => status! <= 500,
|
||||
receiveDataWhenStatusError: true,
|
||||
contentType: Headers.jsonContentType,
|
||||
responseType: ResponseType.json,
|
||||
responseType: ResponseType.json,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -32,7 +32,7 @@ fetchTrip(
|
||||
) async {
|
||||
Map<String, dynamic> data = {
|
||||
"preferences": preferences.toJson(),
|
||||
"start": trip.landmarks!.first.location,
|
||||
"start": trip.landmarks.first.location,
|
||||
};
|
||||
String dataString = jsonEncode(data);
|
||||
log(dataString);
|
||||
@@ -45,7 +45,7 @@ fetchTrip(
|
||||
);
|
||||
} catch (e) {
|
||||
trip.updateUUID("error");
|
||||
|
||||
|
||||
// Format the error message to be more user friendly
|
||||
String errorDescription;
|
||||
if (e is DioException) {
|
||||
@@ -93,7 +93,7 @@ fetchTrip(
|
||||
// Actualy no need to throw an exception, we can just log the error and let the user retry
|
||||
// throw Exception(errorDetail);
|
||||
} else {
|
||||
|
||||
|
||||
// if the response data is not json, throw an error
|
||||
if (response.data is! Map<String, dynamic>) {
|
||||
log("${response.data.runtimeType}");
|
||||
@@ -142,7 +142,7 @@ patchLandmarkImage(Landmark landmark) async {
|
||||
// the image is a google photos link, we should get the image behind the link
|
||||
String? newUrl = await getImageUrlFromGooglePhotos(landmark.imageURL!);
|
||||
// also set the new url if it is null
|
||||
landmark.imageURL = newUrl;
|
||||
landmark.imageURL = newUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,28 +23,28 @@ Widget getFirstPage() {
|
||||
if (snapshot.connectionState == ConnectionState.done) {
|
||||
if (snapshot.hasData) {
|
||||
List<Trip> trips = snapshot.data!;
|
||||
if (trips.length > 0) {
|
||||
if (trips.isNotEmpty) {
|
||||
return TripPage(trip: trips[0]);
|
||||
} else {
|
||||
return NoTripsPage();
|
||||
return const NoTripsPage();
|
||||
}
|
||||
} else {
|
||||
return Center(child: CircularProgressIndicator());
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
} else {
|
||||
return Center(child: CircularProgressIndicator());
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return OnboardingPage();
|
||||
return const OnboardingPage();
|
||||
}
|
||||
} else {
|
||||
return OnboardingPage();
|
||||
return const OnboardingPage();
|
||||
}
|
||||
} else {
|
||||
return OnboardingPage();
|
||||
return const OnboardingPage();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
241
frontend/lib/presentation/pages/create_trip.dart
Normal file
241
frontend/lib/presentation/pages/create_trip.dart
Normal file
@@ -0,0 +1,241 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:anyway/domain/entities/preferences.dart';
|
||||
import 'package:anyway/presentation/providers/trip_provider.dart';
|
||||
import 'package:anyway/presentation/pages/new_trip_preferences.dart';
|
||||
import 'package:anyway/presentation/pages/trip_creation_flow.dart';
|
||||
import 'package:anyway/domain/entities/landmark.dart';
|
||||
import 'package:anyway/presentation/providers/landmark_providers.dart';
|
||||
|
||||
class NewTripPage extends ConsumerStatefulWidget {
|
||||
const NewTripPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<NewTripPage> createState() => _NewTripPageState();
|
||||
}
|
||||
|
||||
class _NewTripPageState extends ConsumerState<NewTripPage> {
|
||||
int _currentStep = 0;
|
||||
|
||||
bool _isCreating = false;
|
||||
List<double>? _selectedStartLocation;
|
||||
|
||||
bool get _hasSelectedLocation => _selectedStartLocation != null;
|
||||
|
||||
Future<void> _pickLocation() async {
|
||||
final result = await Navigator.of(context).push<List<double>>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
const TripLocationSelectionPage(autoNavigateToPreferences: false),
|
||||
),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
if (result != null) {
|
||||
setState(() => _selectedStartLocation = result);
|
||||
}
|
||||
}
|
||||
|
||||
void _openPreferencesPage() {
|
||||
if (!_hasSelectedLocation) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Select a start location first.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
NewTripPreferencesPage(startLocation: _selectedStartLocation!),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showSelectLocationReminder() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Choose a start location to continue.')),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildIntermediateLandmarks(List<Landmark> landmarks) {
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Fetched landmarks',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (landmarks.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
'No landmarks fetched yet. Create a trip to load candidates.',
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
height: 160,
|
||||
child: ListView.separated(
|
||||
itemCount: landmarks.length,
|
||||
itemBuilder: (context, index) {
|
||||
final lm = landmarks[index];
|
||||
final coords = lm.location;
|
||||
final subtitle = coords.length >= 2
|
||||
? 'Lat ${coords[0].toStringAsFixed(4)}, Lon ${coords[1].toStringAsFixed(4)}'
|
||||
: 'Coordinates unavailable';
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.place),
|
||||
title: Text(lm.name),
|
||||
subtitle: Text(subtitle),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) =>
|
||||
const Divider(height: 0),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final intermediateLandmarks = ref.watch(intermediateLandmarksProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Create New Trip'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Preferences',
|
||||
icon: const Icon(Icons.tune),
|
||||
onPressed: _openPreferencesPage,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Stepper(
|
||||
currentStep: _currentStep,
|
||||
onStepContinue: () async {
|
||||
if (_currentStep == 0) {
|
||||
if (!_hasSelectedLocation) {
|
||||
_showSelectLocationReminder();
|
||||
return;
|
||||
}
|
||||
setState(() => _currentStep = 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// final step: create trip with current preferences
|
||||
if (_isCreating) return;
|
||||
setState(() => _isCreating = true);
|
||||
|
||||
if (!_hasSelectedLocation) {
|
||||
_showSelectLocationReminder();
|
||||
setState(() {
|
||||
_isCreating = false;
|
||||
_currentStep = 0;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// For now use a minimal Preferences object; UI should supply real values later.
|
||||
final prefs = Preferences(
|
||||
scores: {'sightseeing': 3, 'shopping': 1, 'nature': 2},
|
||||
maxTimeMinutes: 120,
|
||||
startLocation: _selectedStartLocation!,
|
||||
);
|
||||
final createTrip = ref.read(createTripProvider);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final navigator = Navigator.of(context);
|
||||
try {
|
||||
final trip = await createTrip(prefs);
|
||||
// Show success and (later) navigate to trip viewer
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('Trip created: ${trip.uuid}')),
|
||||
);
|
||||
navigator.pop();
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('Failed to create trip: $e')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isCreating = false);
|
||||
}
|
||||
}
|
||||
},
|
||||
onStepCancel: () {
|
||||
if (_currentStep > 0) {
|
||||
setState(() {
|
||||
_currentStep -= 1;
|
||||
});
|
||||
}
|
||||
},
|
||||
steps: [
|
||||
Step(
|
||||
title: const Text('Select Location'),
|
||||
state: _hasSelectedLocation
|
||||
? StepState.complete
|
||||
: StepState.indexed,
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_hasSelectedLocation
|
||||
? 'Selected: ${_selectedStartLocation![0].toStringAsFixed(4)}, ${_selectedStartLocation![1].toStringAsFixed(4)}'
|
||||
: 'Pick the starting point for your trip.',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.map),
|
||||
label: Text(
|
||||
_hasSelectedLocation
|
||||
? 'Change location'
|
||||
: 'Pick on map',
|
||||
),
|
||||
onPressed: _pickLocation,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
isActive: _currentStep >= 0,
|
||||
),
|
||||
Step(
|
||||
title: const Text('Choose Options'),
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: const [
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: Center(child: Text('Options placeholder')),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text('Tap the tuner icon to fine-tune preferences.'),
|
||||
],
|
||||
),
|
||||
isActive: _currentStep >= 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: _buildIntermediateLandmarks(intermediateLandmarks),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
12
frontend/lib/presentation/pages/login.dart
Normal file
12
frontend/lib/presentation/pages/login.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class LoginPage extends StatelessWidget {
|
||||
const LoginPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(child: Text('Login Page')),
|
||||
);
|
||||
}
|
||||
}
|
||||
161
frontend/lib/presentation/pages/new_trip_preferences.dart
Normal file
161
frontend/lib/presentation/pages/new_trip_preferences.dart
Normal file
@@ -0,0 +1,161 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:anyway/domain/entities/preferences.dart';
|
||||
import 'package:anyway/presentation/pages/trip_details_page.dart';
|
||||
import 'package:anyway/presentation/providers/trip_provider.dart';
|
||||
|
||||
class NewTripPreferencesPage extends ConsumerStatefulWidget {
|
||||
const NewTripPreferencesPage({super.key, required this.startLocation});
|
||||
|
||||
final List<double> startLocation;
|
||||
|
||||
@override
|
||||
ConsumerState<NewTripPreferencesPage> createState() =>
|
||||
_NewTripPreferencesPageState();
|
||||
}
|
||||
|
||||
class _NewTripPreferencesPageState
|
||||
extends ConsumerState<NewTripPreferencesPage> {
|
||||
int _sightseeing = 3;
|
||||
int _shopping = 1;
|
||||
int _nature = 2;
|
||||
int _maxTimeMinutes = 90;
|
||||
bool _isCreating = false;
|
||||
|
||||
Widget _preferenceSlider(
|
||||
String name,
|
||||
int value,
|
||||
ValueChanged<int> onChanged,
|
||||
Icon icon,
|
||||
) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: ListTile(
|
||||
leading: icon,
|
||||
title: Text(name),
|
||||
subtitle: Slider(
|
||||
value: value.toDouble(),
|
||||
min: 0,
|
||||
max: 5,
|
||||
divisions: 5,
|
||||
label: value.toString(),
|
||||
onChanged: (v) => onChanged(v.toInt()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _durationPicker() {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.access_time),
|
||||
title: const Text('Maximum trip duration'),
|
||||
subtitle: CupertinoTimerPicker(
|
||||
mode: CupertinoTimerPickerMode.hm,
|
||||
initialTimerDuration: Duration(minutes: _maxTimeMinutes),
|
||||
minuteInterval: 15,
|
||||
onTimerDurationChanged: (Duration newDuration) {
|
||||
setState(() {
|
||||
_maxTimeMinutes = newDuration.inMinutes;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onCreatePressed() async {
|
||||
if (_isCreating) return;
|
||||
setState(() => _isCreating = true);
|
||||
final createTrip = ref.read(createTripProvider);
|
||||
|
||||
final prefs = Preferences(
|
||||
scores: {
|
||||
'sightseeing': _sightseeing,
|
||||
'shopping': _shopping,
|
||||
'nature': _nature,
|
||||
},
|
||||
maxTimeMinutes: _maxTimeMinutes,
|
||||
startLocation: widget.startLocation,
|
||||
);
|
||||
|
||||
try {
|
||||
final trip = await createTrip(prefs);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Trip created: ${trip.uuid}')));
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (_) => TripDetailsPage(trip: trip)),
|
||||
(route) => route.isFirst,
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Failed to create trip: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _isCreating = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Trip Preferences')),
|
||||
body: ListView(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
'Tell us about your ideal trip.',
|
||||
style: TextStyle(fontSize: 18),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.place),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Start: ${widget.startLocation[0].toStringAsFixed(4)}, ${widget.startLocation[1].toStringAsFixed(4)}',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_durationPicker(),
|
||||
_preferenceSlider(
|
||||
'Sightseeing',
|
||||
_sightseeing,
|
||||
(v) => setState(() => _sightseeing = v),
|
||||
const Icon(Icons.camera_alt),
|
||||
),
|
||||
_preferenceSlider(
|
||||
'Shopping',
|
||||
_shopping,
|
||||
(v) => setState(() => _shopping = v),
|
||||
const Icon(Icons.shopping_bag),
|
||||
),
|
||||
_preferenceSlider(
|
||||
'Nature',
|
||||
_nature,
|
||||
(v) => setState(() => _nature = v),
|
||||
const Icon(Icons.park),
|
||||
),
|
||||
const SizedBox(height: 120), // padding for floating button
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _isCreating ? null : _onCreatePressed,
|
||||
label: _isCreating
|
||||
? const Text('Creating...')
|
||||
: const Text('Create Trip'),
|
||||
icon: const Icon(Icons.playlist_add),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:anyway/constants.dart';
|
||||
import 'package:anyway/modules/onbarding_agreement_card.dart';
|
||||
import 'package:anyway/modules/onboarding_card.dart';
|
||||
import 'package:anyway/pages/new_trip_location.dart';
|
||||
import 'package:anyway/structs/agreement.dart';
|
||||
import 'package:anyway/core/constants.dart';
|
||||
import 'package:anyway/presentation/providers/onboarding_state_provider.dart';
|
||||
import 'package:anyway/presentation/widgets/onbarding_agreement_card.dart';
|
||||
import 'package:anyway/presentation/widgets/onboarding_card.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
|
||||
List<Widget> onboardingCards = [
|
||||
@@ -33,14 +32,14 @@ List<Widget> onboardingCards = [
|
||||
];
|
||||
|
||||
|
||||
class OnboardingPage extends StatefulWidget {
|
||||
class OnboardingPage extends ConsumerStatefulWidget {
|
||||
const OnboardingPage({super.key});
|
||||
|
||||
@override
|
||||
State<OnboardingPage> createState() => _OnboardingPageState();
|
||||
ConsumerState<OnboardingPage> createState() => _OnboardingPageState();
|
||||
}
|
||||
|
||||
class _OnboardingPageState extends State<OnboardingPage> {
|
||||
class _OnboardingPageState extends ConsumerState<OnboardingPage> {
|
||||
final PageController _controller = PageController();
|
||||
late List<Widget> fullCards;
|
||||
|
||||
@@ -116,46 +115,58 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
if ((_controller.hasClients ? (_controller.page ?? _controller.initialPage) : 0) != fullCards.length - 1) {
|
||||
return FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
controller.nextPage(duration: Duration(milliseconds: 500), curve: Curves.ease);
|
||||
controller.nextPage(duration: const Duration(milliseconds: 500), curve: Curves.ease);
|
||||
},
|
||||
label: Icon(Icons.arrow_forward)
|
||||
label: const Icon(Icons.arrow_forward)
|
||||
);
|
||||
} else {
|
||||
// only allow the user to proceed if they have agreed to the terms and conditions
|
||||
Future<bool> hasAgreed = getAgreement().then((agreement) => agreement.agreed);
|
||||
// only allow the user to proceed if they have agreed to the terms and conditions
|
||||
// the information is accessible through ref.watch(onboardingStateProvider)
|
||||
|
||||
return FutureBuilder(
|
||||
future: hasAgreed,
|
||||
builder: (context, snapshot){
|
||||
if (snapshot.hasData && snapshot.data!) {
|
||||
return FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const NewTripPage()
|
||||
)
|
||||
);
|
||||
},
|
||||
label: const Row(
|
||||
children: [
|
||||
Text("Start planning!"),
|
||||
Padding(padding: EdgeInsets.only(right: 8.0)),
|
||||
Icon(Icons.map_outlined)
|
||||
],
|
||||
)
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
}
|
||||
);
|
||||
Widget disabledWidget = FloatingActionButton.extended(
|
||||
onPressed: null,
|
||||
label: const Row(
|
||||
children: [
|
||||
Text("Start planning!"),
|
||||
Padding(padding: EdgeInsets.only(right: 8.0)),
|
||||
Icon(Icons.map_outlined)
|
||||
],
|
||||
)
|
||||
);
|
||||
|
||||
return ref.watch(onboardingStateProvider).when(
|
||||
data: (isOnboarded) {
|
||||
if (isOnboarded) {
|
||||
return FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
// proceed to the next page - pop the onboarding page so the StartPage can decide what to show next
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
label: const Row(
|
||||
children: [
|
||||
Text("Start planning!"),
|
||||
Padding(padding: EdgeInsets.only(right: 8.0)),
|
||||
Icon(Icons.map_outlined)
|
||||
],
|
||||
)
|
||||
);
|
||||
} else {
|
||||
return disabledWidget;
|
||||
}
|
||||
},
|
||||
loading: () => disabledWidget,
|
||||
error: (error, stack) => disabledWidget,
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
void onAgreementChanged(bool value) async {
|
||||
saveAgreement(value);
|
||||
// Update the state of the OnboardingPage
|
||||
setState(() {});
|
||||
await ref.read(onboardingControllerProvider).setOnboarded(value);
|
||||
|
||||
setState(() {
|
||||
// rebuild to show the next button
|
||||
|
||||
});
|
||||
}
|
||||
}
|
||||
103
frontend/lib/presentation/pages/start.dart
Normal file
103
frontend/lib/presentation/pages/start.dart
Normal file
@@ -0,0 +1,103 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:anyway/presentation/providers/onboarding_state_provider.dart';
|
||||
import 'package:anyway/domain/entities/trip.dart';
|
||||
import 'package:anyway/presentation/pages/login.dart';
|
||||
import 'package:anyway/presentation/pages/onboarding.dart';
|
||||
import 'package:anyway/presentation/pages/trip_creation_flow.dart';
|
||||
import 'package:anyway/presentation/pages/trip_details_page.dart';
|
||||
import 'package:anyway/presentation/providers/trip_provider.dart';
|
||||
import 'package:anyway/presentation/widgets/trip_summary_card.dart';
|
||||
|
||||
// TODO - Replace with actual auth state logic
|
||||
final authStateProvider = FutureProvider<bool>(
|
||||
(ref) async => true,
|
||||
);
|
||||
|
||||
class StartPage extends ConsumerWidget {
|
||||
const StartPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// the home page is dependent on the state of the providers:
|
||||
// - if the user is not onboarded, show the onboarding flow
|
||||
// - if the user is not logged in, show the login page
|
||||
// - if there are no trips available, show the trip creation page
|
||||
// - else: show the overview page that shows the last trip
|
||||
|
||||
final onboardingState = ref.watch(onboardingStateProvider);
|
||||
final authState = ref.watch(authStateProvider);
|
||||
final currentTrips = ref.watch(currentTripsProvider);
|
||||
|
||||
return onboardingState.when(
|
||||
data: (isOnboarded) {
|
||||
if (!isOnboarded) {
|
||||
return const OnboardingPage();
|
||||
}
|
||||
|
||||
return authState.when(
|
||||
data: (isLoggedIn) {
|
||||
if (!isLoggedIn) {
|
||||
return const LoginPage();
|
||||
}
|
||||
|
||||
|
||||
return currentTrips.when(
|
||||
data: (trips) {
|
||||
if (trips.isEmpty) {
|
||||
return const TripLocationSelectionPage();
|
||||
}
|
||||
return TripsOverviewPage(trips: trips);
|
||||
},
|
||||
loading: () => const Scaffold(body: Center(child: CircularProgressIndicator())),
|
||||
error: (error, stack) => Scaffold(body: Center(child: Text('Error loading trips: $error'))),
|
||||
);
|
||||
},
|
||||
loading: () => const Scaffold(body: Center(child: CircularProgressIndicator())),
|
||||
error: (error, stack) => Scaffold(body: Center(child: Text('Error: $error'))),
|
||||
);
|
||||
},
|
||||
loading: () => const Scaffold(body: Center(child: CircularProgressIndicator())),
|
||||
error: (error, stack) => Scaffold(body: Center(child: Text('Error: $error'))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO - move to separate file
|
||||
class TripsOverviewPage extends StatelessWidget {
|
||||
const TripsOverviewPage({super.key, required this.trips});
|
||||
|
||||
final List<Trip> trips;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Your trips')),
|
||||
body: ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: trips.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 16),
|
||||
itemBuilder: (context, index) {
|
||||
final trip = trips[index];
|
||||
return TripSummaryCard(
|
||||
trip: trip,
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => TripDetailsPage(trip: trip)),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => const TripLocationSelectionPage()));
|
||||
},
|
||||
icon: const Icon(Icons.map),
|
||||
label: const Text('Plan your next trip'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
287
frontend/lib/presentation/pages/trip_creation_flow.dart
Normal file
287
frontend/lib/presentation/pages/trip_creation_flow.dart
Normal file
@@ -0,0 +1,287 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_maps_flutter/google_maps_flutter.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:geocoding/geocoding.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
import 'package:anyway/core/constants.dart';
|
||||
import 'package:anyway/presentation/pages/new_trip_preferences.dart';
|
||||
|
||||
class TripLocationSelectionPage extends StatefulWidget {
|
||||
const TripLocationSelectionPage({
|
||||
super.key,
|
||||
this.autoNavigateToPreferences = true,
|
||||
});
|
||||
|
||||
final bool autoNavigateToPreferences;
|
||||
|
||||
@override
|
||||
State<TripLocationSelectionPage> createState() =>
|
||||
_TripLocationSelectionPageState();
|
||||
}
|
||||
|
||||
class _TripLocationSelectionPageState extends State<TripLocationSelectionPage> {
|
||||
final CameraPosition _initialCameraPosition = const CameraPosition(
|
||||
// TODO - maybe Paris is not the best default?
|
||||
target: LatLng(48.8566, 2.3522),
|
||||
zoom: 11.0,
|
||||
);
|
||||
|
||||
GoogleMapController? _mapController;
|
||||
LatLng? _selectedLocation;
|
||||
bool _useLocation = true;
|
||||
bool _loadingPreferences = true;
|
||||
bool _isSearchingAddress = false;
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
|
||||
static const Map<String, List<double>> _debugLocations = {
|
||||
'paris': [48.8575, 2.3514],
|
||||
'london': [51.5074, -0.1278],
|
||||
'new york': [40.7128, -74.006],
|
||||
'tokyo': [35.6895, 139.6917],
|
||||
};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadLocationPreference();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadLocationPreference() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final useLocation = prefs.getBool('useLocation') ?? true;
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_useLocation = useLocation;
|
||||
_loadingPreferences = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _onLongPress(LatLng location) {
|
||||
setState(() {
|
||||
_selectedLocation = location;
|
||||
});
|
||||
_mapController?.animateCamera(CameraUpdate.newLatLng(location));
|
||||
}
|
||||
|
||||
void _setSelectedLocationFromCoords(double lat, double lng) {
|
||||
final latLng = LatLng(lat, lng);
|
||||
setState(() {
|
||||
_selectedLocation = latLng;
|
||||
});
|
||||
_mapController?.animateCamera(CameraUpdate.newLatLng(latLng));
|
||||
}
|
||||
|
||||
Future<void> _useCurrentLocation() async {
|
||||
try {
|
||||
var permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
}
|
||||
|
||||
if (permission == LocationPermission.denied) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Location permission denied.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (permission == LocationPermission.deniedForever) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Location permission permanently denied.'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final position = await Geolocator.getCurrentPosition();
|
||||
if (!mounted) return;
|
||||
_setSelectedLocationFromCoords(position.latitude, position.longitude);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Unable to get current location: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _searchForLocation(String rawQuery) async {
|
||||
final query = rawQuery.trim();
|
||||
if (query.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSearchingAddress = true);
|
||||
try {
|
||||
List<Location> locations = [];
|
||||
if (GeocodingPlatform.instance != null) {
|
||||
locations = await locationFromAddress(query);
|
||||
}
|
||||
|
||||
Location? selected;
|
||||
if (locations.isNotEmpty) {
|
||||
selected = locations.first;
|
||||
} else {
|
||||
final fallback = _debugLocations[query.toLowerCase()];
|
||||
if (fallback != null) {
|
||||
selected = Location(
|
||||
latitude: fallback[0],
|
||||
longitude: fallback[1],
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (selected == null) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('No results for "$query".')));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
_setSelectedLocationFromCoords(selected.latitude, selected.longitude);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Search failed: $e')));
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isSearchingAddress = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Set<Marker> get _markers => _selectedLocation == null
|
||||
? <Marker>{}
|
||||
: {
|
||||
Marker(
|
||||
markerId: const MarkerId('new-trip-start'),
|
||||
position: _selectedLocation!,
|
||||
infoWindow: const InfoWindow(title: 'Trip start'),
|
||||
),
|
||||
};
|
||||
|
||||
void _confirmLocation() {
|
||||
if (_selectedLocation == null) return;
|
||||
final startLocation = <double>[
|
||||
_selectedLocation!.latitude,
|
||||
_selectedLocation!.longitude,
|
||||
];
|
||||
|
||||
if (!widget.autoNavigateToPreferences) {
|
||||
Navigator.of(context).pop(startLocation);
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => NewTripPreferencesPage(startLocation: startLocation),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_loadingPreferences) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Choose Start Location')),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Choose Start Location')),
|
||||
body: Stack(
|
||||
children: [
|
||||
GoogleMap(
|
||||
onMapCreated: (controller) => _mapController = controller,
|
||||
initialCameraPosition: _initialCameraPosition,
|
||||
onLongPress: _onLongPress,
|
||||
markers: _markers,
|
||||
cloudMapId: MAP_ID,
|
||||
mapToolbarEnabled: false,
|
||||
zoomControlsEnabled: false,
|
||||
myLocationButtonEnabled: false,
|
||||
myLocationEnabled: _useLocation,
|
||||
),
|
||||
Positioned(
|
||||
top: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: Column(
|
||||
children: [
|
||||
SearchBar(
|
||||
controller: _searchController,
|
||||
hintText: 'Enter a city or long-press on the map',
|
||||
leading: const Icon(Icons.search),
|
||||
onSubmitted: _searchForLocation,
|
||||
trailing: [
|
||||
IconButton(
|
||||
icon: _isSearchingAddress
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.send),
|
||||
onPressed: _isSearchingAddress
|
||||
? null
|
||||
: () => _searchForLocation(_searchController.text),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_useLocation)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(Icons.my_location),
|
||||
label: const Text('Use current location'),
|
||||
onPressed: _useCurrentLocation,
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
_selectedLocation == null
|
||||
? 'Long-press anywhere to drop the starting point.'
|
||||
: 'Selected: ${_selectedLocation!.latitude.toStringAsFixed(4)}, ${_selectedLocation!.longitude.toStringAsFixed(4)}',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: _selectedLocation == null
|
||||
? null
|
||||
: FloatingActionButton.extended(
|
||||
onPressed: _confirmLocation,
|
||||
icon: widget.autoNavigateToPreferences
|
||||
? const Icon(Icons.tune)
|
||||
: const Icon(Icons.check),
|
||||
label: Text(
|
||||
widget.autoNavigateToPreferences
|
||||
? 'Select Preferences'
|
||||
: 'Use this location',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
65
frontend/lib/presentation/pages/trip_details_page.dart
Normal file
65
frontend/lib/presentation/pages/trip_details_page.dart
Normal file
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sliding_up_panel/sliding_up_panel.dart';
|
||||
|
||||
import 'package:anyway/core/constants.dart';
|
||||
import 'package:anyway/domain/entities/trip.dart';
|
||||
import 'package:anyway/presentation/utils/trip_location_utils.dart';
|
||||
import 'package:anyway/presentation/widgets/trip_details_panel.dart';
|
||||
import 'package:anyway/presentation/widgets/trip_map.dart';
|
||||
|
||||
class TripDetailsPage extends StatefulWidget {
|
||||
const TripDetailsPage({super.key, required this.trip});
|
||||
|
||||
final Trip trip;
|
||||
|
||||
@override
|
||||
State<TripDetailsPage> createState() => _TripDetailsPageState();
|
||||
}
|
||||
|
||||
class _TripDetailsPageState extends State<TripDetailsPage> {
|
||||
late Future<bool> _locationPrefFuture;
|
||||
late Future<TripLocaleInfo> _localeFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_locationPrefFuture = _loadLocationPreference();
|
||||
_localeFuture = TripLocationUtils.resolveLocaleInfo(widget.trip);
|
||||
}
|
||||
|
||||
Future<bool> _loadLocationPreference() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool('useLocation') ?? true;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: FutureBuilder<TripLocaleInfo>(
|
||||
future: _localeFuture,
|
||||
builder: (context, snapshot) {
|
||||
final locale = snapshot.data;
|
||||
final title = locale?.cityName ?? 'Trip overview';
|
||||
return Text(title);
|
||||
},
|
||||
),
|
||||
),
|
||||
body: FutureBuilder<bool>(
|
||||
future: _locationPrefFuture,
|
||||
builder: (context, snapshot) {
|
||||
final enableLocation = snapshot.data ?? false;
|
||||
return SlidingUpPanel(
|
||||
borderRadius: const BorderRadius.only(topLeft: Radius.circular(24), topRight: Radius.circular(24)),
|
||||
parallaxEnabled: true,
|
||||
maxHeight: MediaQuery.of(context).size.height * TRIP_PANEL_MAX_HEIGHT,
|
||||
minHeight: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT,
|
||||
panelBuilder: (controller) => TripDetailsPanel(trip: widget.trip, controller: controller),
|
||||
body: TripMap(trip: widget.trip, showRoute: true, interactive: true, borderRadius: 0, enableMyLocation: enableLocation),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
45
frontend/lib/presentation/providers/core_providers.dart
Normal file
45
frontend/lib/presentation/providers/core_providers.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
import 'package:anyway/core/dio_client.dart';
|
||||
import 'package:anyway/data/datasources/trip_local_datasource.dart';
|
||||
import 'package:anyway/data/datasources/trip_remote_datasource.dart';
|
||||
import 'package:anyway/data/repositories/backend_trip_repository.dart';
|
||||
import 'package:anyway/data/repositories/local_onboarding_repository.dart';
|
||||
import 'package:anyway/data/repositories/preferences_repository_impl.dart';
|
||||
import 'package:anyway/domain/repositories/onboarding_repository.dart';
|
||||
import 'package:anyway/domain/repositories/preferences_repository.dart';
|
||||
import 'package:anyway/domain/repositories/trip_repository.dart';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// Provide a configured Dio instance (used by data layer)
|
||||
final dioProvider = Provider<Dio>((ref) {
|
||||
// baseUrl can be configured via environment; use a sensible default
|
||||
return Dio(BaseOptions(
|
||||
baseUrl: 'https://anyway.anydev.info',
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 120),
|
||||
headers: {
|
||||
HttpHeaders.acceptHeader: 'application/json',
|
||||
HttpHeaders.contentTypeHeader: 'application/json',
|
||||
},
|
||||
));
|
||||
});
|
||||
|
||||
/// Onboarding repository backed by SharedPreferences
|
||||
final onboardingRepositoryProvider = Provider<OnboardingRepository>((ref) {
|
||||
return LocalOnboardingRepository();
|
||||
});
|
||||
|
||||
/// Preferences repository (local persistence)
|
||||
final preferencesRepositoryProvider = Provider<PreferencesRepository>((ref) {
|
||||
return PreferencesRepositoryImpl();
|
||||
});
|
||||
|
||||
/// Trip repository backed by the backend
|
||||
final tripRepositoryProvider = Provider<TripRepository>((ref) {
|
||||
final dio = ref.read(dioProvider);
|
||||
final remote = TripRemoteDataSourceImpl(dio: dio);
|
||||
final local = TripLocalDataSourceImpl();
|
||||
return BackendTripRepository(remote: remote, local: local);
|
||||
});
|
||||
16
frontend/lib/presentation/providers/landmark_providers.dart
Normal file
16
frontend/lib/presentation/providers/landmark_providers.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:anyway/domain/entities/landmark.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class IntermediateLandmarksNotifier extends Notifier<List<Landmark>> {
|
||||
@override
|
||||
List<Landmark> build() => [];
|
||||
|
||||
void setLandmarks(List<Landmark> landmarks) {
|
||||
state = List.unmodifiable(landmarks);
|
||||
}
|
||||
|
||||
void clear() => state = [];
|
||||
}
|
||||
|
||||
final intermediateLandmarksProvider =
|
||||
NotifierProvider<IntermediateLandmarksNotifier, List<Landmark>>(IntermediateLandmarksNotifier.new);
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:anyway/domain/repositories/onboarding_repository.dart';
|
||||
import 'package:anyway/presentation/providers/core_providers.dart';
|
||||
|
||||
final onboardingStateProvider = FutureProvider<bool>((ref) async {
|
||||
final repo = ref.watch(onboardingRepositoryProvider);
|
||||
return repo.isOnboarded();
|
||||
});
|
||||
|
||||
|
||||
final onboardingControllerProvider = Provider<OnboardingController>((ref) {
|
||||
return OnboardingController(ref);
|
||||
});
|
||||
|
||||
class OnboardingController {
|
||||
final Ref ref;
|
||||
OnboardingController(this.ref);
|
||||
|
||||
Future<void> setOnboarded(bool value) async {
|
||||
final repo = ref.read(onboardingRepositoryProvider);
|
||||
await repo.setOnboarded(value);
|
||||
// refresh the read provider so UI updates
|
||||
ref.invalidate(onboardingStateProvider);
|
||||
}
|
||||
}
|
||||
|
||||
86
frontend/lib/presentation/providers/trip_provider.dart
Normal file
86
frontend/lib/presentation/providers/trip_provider.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:anyway/domain/entities/preferences.dart';
|
||||
import 'package:anyway/domain/entities/trip.dart';
|
||||
import 'package:anyway/domain/repositories/trip_repository.dart';
|
||||
import 'package:anyway/presentation/providers/landmark_providers.dart';
|
||||
import 'package:anyway/presentation/providers/core_providers.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
// Provides a function that creates a trip given preferences.
|
||||
final createTripProvider = Provider<Future<Trip> Function(Preferences)>((ref) {
|
||||
return (Preferences prefs) async {
|
||||
final repo = ref.read(tripRepositoryProvider);
|
||||
final landmarks = await repo.searchLandmarks(prefs);
|
||||
ref.read(intermediateLandmarksProvider.notifier).setLandmarks(landmarks);
|
||||
final trip = await repo.getTrip(preferences: prefs, landmarks: landmarks);
|
||||
await ref.read(currentTripsProvider.notifier).saveTrip(trip);
|
||||
return trip;
|
||||
};
|
||||
});
|
||||
|
||||
class CurrentTripsNotifier extends AsyncNotifier<List<Trip>> {
|
||||
TripRepository get _repository => ref.read(tripRepositoryProvider);
|
||||
Future<List<Trip>>? _pendingLoad;
|
||||
|
||||
List<Trip> _currentTrips() => state.asData?.value ?? const <Trip>[];
|
||||
|
||||
@override
|
||||
Future<List<Trip>> build() async {
|
||||
final load = _repository.getSavedTrips();
|
||||
_pendingLoad = load;
|
||||
try {
|
||||
final trips = await load;
|
||||
return List.unmodifiable(trips);
|
||||
} finally {
|
||||
if (identical(_pendingLoad, load)) {
|
||||
_pendingLoad = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncValue.loading();
|
||||
final load = _repository.getSavedTrips();
|
||||
_pendingLoad = load;
|
||||
state = await AsyncValue.guard(() async {
|
||||
final trips = await load;
|
||||
return List.unmodifiable(trips);
|
||||
});
|
||||
if (identical(_pendingLoad, load)) {
|
||||
_pendingLoad = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveTrip(Trip trip) async {
|
||||
final previous = state;
|
||||
final updatedTrips = [..._currentTrips()]
|
||||
..removeWhere((t) => t.uuid == trip.uuid)
|
||||
..insert(0, trip);
|
||||
state = AsyncValue.data(List.unmodifiable(updatedTrips));
|
||||
|
||||
try {
|
||||
await _repository.saveTrip(trip);
|
||||
} catch (error) {
|
||||
state = previous;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteTrip(String uuid) async {
|
||||
final previous = state;
|
||||
final updatedTrips = _currentTrips()
|
||||
.where((trip) => trip.uuid != uuid)
|
||||
.toList(growable: false);
|
||||
state = AsyncValue.data(List.unmodifiable(updatedTrips));
|
||||
|
||||
try {
|
||||
await _repository.deleteSavedTrip(uuid);
|
||||
} catch (error) {
|
||||
state = previous;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final currentTripsProvider = AsyncNotifierProvider<CurrentTripsNotifier, List<Trip>>(CurrentTripsNotifier.new);
|
||||
78
frontend/lib/presentation/utils/trip_location_utils.dart
Normal file
78
frontend/lib/presentation/utils/trip_location_utils.dart
Normal file
@@ -0,0 +1,78 @@
|
||||
import 'package:anyway/domain/entities/trip.dart';
|
||||
import 'package:geocoding/geocoding.dart';
|
||||
|
||||
class TripLocationUtils {
|
||||
const TripLocationUtils._();
|
||||
|
||||
static List<double>? startCoordinates(Trip trip) {
|
||||
if (trip.landmarks.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final coords = trip.landmarks.first.location;
|
||||
if (coords.length < 2) {
|
||||
return null;
|
||||
}
|
||||
return coords;
|
||||
}
|
||||
|
||||
static Future<TripLocaleInfo> resolveLocaleInfo(Trip trip) async {
|
||||
final coords = startCoordinates(trip);
|
||||
if (coords == null) {
|
||||
return const TripLocaleInfo();
|
||||
}
|
||||
|
||||
if (GeocodingPlatform.instance == null) {
|
||||
final fallbackCity = '${coords[0].toStringAsFixed(2)}, ${coords[1].toStringAsFixed(2)}';
|
||||
return TripLocaleInfo(cityName: fallbackCity, coordinates: coords);
|
||||
}
|
||||
|
||||
try {
|
||||
final placemarks = await placemarkFromCoordinates(coords[0], coords[1]);
|
||||
if (placemarks.isEmpty) {
|
||||
return TripLocaleInfo(coordinates: coords);
|
||||
}
|
||||
final placemark = placemarks.first;
|
||||
final city = placemark.locality ?? placemark.subAdministrativeArea;
|
||||
final country = placemark.country;
|
||||
final isoCountryCode = placemark.isoCountryCode;
|
||||
return TripLocaleInfo(cityName: city, countryName: country, countryCode: isoCountryCode, flagEmoji: _flagEmoji(isoCountryCode), coordinates: coords);
|
||||
} catch (_) {
|
||||
return TripLocaleInfo(coordinates: coords);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String> resolveCityName(Trip trip) async {
|
||||
final localeInfo = await resolveLocaleInfo(trip);
|
||||
return localeInfo.cityName ?? 'Unknown';
|
||||
}
|
||||
|
||||
static String? _flagEmoji(String? countryCode) {
|
||||
if (countryCode == null || countryCode.length != 2) {
|
||||
return null;
|
||||
}
|
||||
final upper = countryCode.toUpperCase();
|
||||
final first = upper.codeUnitAt(0);
|
||||
final second = upper.codeUnitAt(1);
|
||||
if (!_isAsciiLetter(first) || !_isAsciiLetter(second)) {
|
||||
return null;
|
||||
}
|
||||
const base = 0x1F1E6;
|
||||
final firstFlag = base + (first - 0x41);
|
||||
final secondFlag = base + (second - 0x41);
|
||||
return String.fromCharCodes([firstFlag, secondFlag]);
|
||||
}
|
||||
|
||||
static bool _isAsciiLetter(int codeUnit) => codeUnit >= 0x41 && codeUnit <= 0x5A;
|
||||
}
|
||||
|
||||
class TripLocaleInfo {
|
||||
const TripLocaleInfo({this.cityName, this.countryName, this.countryCode, this.flagEmoji, this.coordinates});
|
||||
|
||||
final String? cityName;
|
||||
final String? countryName;
|
||||
final String? countryCode;
|
||||
final String? flagEmoji;
|
||||
final List<double>? coordinates;
|
||||
|
||||
bool get hasResolvedCity => cityName != null && cityName!.trim().isNotEmpty && cityName != 'Unknown';
|
||||
}
|
||||
121
frontend/lib/presentation/widgets/onbarding_agreement_card.dart
Normal file
121
frontend/lib/presentation/widgets/onbarding_agreement_card.dart
Normal file
@@ -0,0 +1,121 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:anyway/presentation/providers/onboarding_state_provider.dart';
|
||||
import 'package:anyway/presentation/widgets/onboarding_card.dart';
|
||||
|
||||
|
||||
|
||||
class OnboardingAgreementCard extends ConsumerStatefulWidget {
|
||||
final String title;
|
||||
final String description;
|
||||
final String imagePath;
|
||||
final String agreementTextPath;
|
||||
final ValueChanged<bool> onAgreementChanged;
|
||||
|
||||
|
||||
const OnboardingAgreementCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.imagePath,
|
||||
required this.agreementTextPath,
|
||||
required this.onAgreementChanged
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<OnboardingAgreementCard> createState() => _OnboardingAgreementCardState();
|
||||
|
||||
}
|
||||
|
||||
class _OnboardingAgreementCardState extends ConsumerState<OnboardingAgreementCard> {
|
||||
|
||||
// @override
|
||||
// void initState() {
|
||||
// super.initState();
|
||||
// // You can use ref here if needed in initState
|
||||
// }
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
OnboardingCard(title: widget.title, description: widget.description, imagePath: widget.imagePath),
|
||||
const Padding(padding: EdgeInsets.only(top: 20)),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// The checkbox of the agreement
|
||||
FutureBuilder(
|
||||
future: ref.read(onboardingStateProvider.future),
|
||||
builder: (context, snapshot) {
|
||||
bool agreed = false;
|
||||
if (snapshot.hasData) {
|
||||
agreed = snapshot.data!;
|
||||
}
|
||||
return Checkbox(
|
||||
value: agreed,
|
||||
onChanged: (bool? value) {
|
||||
if (value != null) {
|
||||
widget.onAgreementChanged(value);
|
||||
}
|
||||
},
|
||||
activeColor: Colors.white,
|
||||
checkColor: Colors.black,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// The text of the agreement
|
||||
Text(
|
||||
"I agree to the ",
|
||||
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
|
||||
// The clickable text of the agreement that shows the agreement text
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
// show a dialog with the agreement text
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
content: FutureBuilder(
|
||||
future: DefaultAssetBundle.of(context).loadString(widget.agreementTextPath),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return MarkdownBody(
|
||||
data: snapshot.data.toString(),
|
||||
);
|
||||
} else {
|
||||
return const CircularProgressIndicator();
|
||||
}
|
||||
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
},
|
||||
child: Text(
|
||||
"Terms of Service (click to view)",
|
||||
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
45
frontend/lib/presentation/widgets/onboarding_card.dart
Normal file
45
frontend/lib/presentation/widgets/onboarding_card.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
class OnboardingCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String description;
|
||||
final String imagePath;
|
||||
|
||||
const OnboardingCard({super.key,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.imagePath,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.headlineLarge!.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const Padding(padding: EdgeInsets.only(top: 20)),
|
||||
SvgPicture.asset(
|
||||
imagePath,
|
||||
height: 200,
|
||||
),
|
||||
const Padding(padding: EdgeInsets.only(top: 20)),
|
||||
Text(
|
||||
description,
|
||||
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
]
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:anyway/core/constants.dart';
|
||||
import 'package:anyway/presentation/utils/trip_location_utils.dart';
|
||||
|
||||
class TripHeroHeader extends StatelessWidget {
|
||||
const TripHeroHeader({super.key, this.localeInfo});
|
||||
|
||||
final TripLocaleInfo? localeInfo;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final resolvedCity = localeInfo?.hasResolvedCity == true ? localeInfo!.cityName : null;
|
||||
final title = resolvedCity == null ? 'Welcome to your trip!' : 'Welcome to $resolvedCity!';
|
||||
final flag = localeInfo?.flagEmoji ?? '🏁';
|
||||
|
||||
return SizedBox(
|
||||
height: 70,
|
||||
child: Center(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
DecoratedBox(
|
||||
decoration: const BoxDecoration(shape: BoxShape.circle, color: Color(0x11000000)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Text(flag, style: const TextStyle(fontSize: 26)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_GradientText(title, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800, letterSpacing: -0.2)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GradientText extends StatelessWidget {
|
||||
const _GradientText(this.text, {this.style});
|
||||
|
||||
final String text;
|
||||
final TextStyle? style;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ShaderMask(
|
||||
shaderCallback: (bounds) => APP_GRADIENT.createShader(Rect.fromLTWH(0, 0, bounds.width, bounds.height)),
|
||||
blendMode: BlendMode.srcIn,
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: (style ?? const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)).copyWith(color: Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class TripHeroMetaCard extends StatelessWidget {
|
||||
const TripHeroMetaCard({super.key, required this.subtitle, required this.totalStops, required this.totalMinutes, this.countryName, this.isLoading = false});
|
||||
|
||||
final String subtitle;
|
||||
final int totalStops;
|
||||
final int? totalMinutes;
|
||||
final String? countryName;
|
||||
final bool isLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final totalTimeLabel = _formatTripDuration(totalMinutes);
|
||||
|
||||
return Card(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)),
|
||||
elevation: 6,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(subtitle, style: theme.textTheme.titleSmall?.copyWith(height: 1.3)),
|
||||
if (countryName != null && countryName!.trim().isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.public, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Text(countryName!, style: theme.textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600, letterSpacing: 0.2)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isLoading) const Padding(padding: EdgeInsets.only(top: 8), child: LinearProgressIndicator(minHeight: 3)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_HeroStat(icon: Icons.flag, label: 'Stops', value: '$totalStops'),
|
||||
const SizedBox(width: 14),
|
||||
_HeroStat(icon: Icons.hourglass_bottom_rounded, label: 'Total time', value: totalTimeLabel),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeroStat extends StatelessWidget {
|
||||
const _HeroStat({required this.icon, required this.label, required this.value});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 18, color: Colors.grey.shade600),
|
||||
const SizedBox(width: 4),
|
||||
Text(label, style: theme.textTheme.labelSmall?.copyWith(color: Colors.grey.shade600)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(value, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatTripDuration(int? totalMinutes) {
|
||||
if (totalMinutes == null) return '--';
|
||||
final hours = totalMinutes ~/ 60;
|
||||
final minutes = totalMinutes % 60;
|
||||
if (hours == 0) return '${minutes}m';
|
||||
if (minutes == 0) return '${hours}h';
|
||||
return '${hours}h ${minutes}m';
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:anyway/domain/entities/landmark.dart';
|
||||
import 'package:anyway/domain/entities/landmark_type.dart';
|
||||
|
||||
class TripLandmarkCard extends StatelessWidget {
|
||||
const TripLandmarkCard({super.key, required this.landmark, required this.position, required this.onToggleVisited, this.onOpenWebsite});
|
||||
|
||||
final Landmark landmark;
|
||||
final int position;
|
||||
final VoidCallback onToggleVisited;
|
||||
final VoidCallback? onOpenWebsite;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final visited = landmark.isVisited;
|
||||
final metaItems = _buildMetaItems(
|
||||
duration: landmark.durationMinutes,
|
||||
reachTime: landmark.timeToReachNextMinutes,
|
||||
tags: landmark.description?.tags ?? const [],
|
||||
tagCount: landmark.tagCount,
|
||||
attractiveness: landmark.attractiveness,
|
||||
isSecondary: landmark.isSecondary ?? false,
|
||||
websiteLabel: landmark.websiteUrl,
|
||||
onOpenWebsite: onOpenWebsite,
|
||||
);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
child: Card(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
elevation: 4,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_LandmarkMedia(landmark: landmark, position: position, type: landmark.type.type),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 14, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
landmark.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
iconSize: 22,
|
||||
padding: EdgeInsets.zero,
|
||||
splashRadius: 18,
|
||||
tooltip: visited ? 'Mark as pending' : 'Mark visited',
|
||||
onPressed: onToggleVisited,
|
||||
icon: Icon(visited ? Icons.radio_button_checked : Icons.radio_button_unchecked, color: visited ? Colors.green : Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (landmark.nameEn != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
landmark.nameEn!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(color: Colors.grey.shade600),
|
||||
),
|
||||
),
|
||||
if (landmark.description?.description != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(landmark.description!.description, maxLines: 2, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyMedium?.copyWith(height: 1.3)),
|
||||
),
|
||||
if (metaItems.isNotEmpty) ...[const SizedBox(height: 10), _MetaScroller(items: metaItems)],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<_MetaItem> _buildMetaItems({
|
||||
int? duration,
|
||||
int? reachTime,
|
||||
List<String> tags = const [],
|
||||
int? tagCount,
|
||||
int? attractiveness,
|
||||
bool isSecondary = false,
|
||||
String? websiteLabel,
|
||||
VoidCallback? onOpenWebsite,
|
||||
}) {
|
||||
final items = <_MetaItem>[];
|
||||
if (duration != null) {
|
||||
items.add(_MetaItem(icon: Icons.timer_outlined, label: '$duration min stay'));
|
||||
}
|
||||
if (reachTime != null) {
|
||||
items.add(_MetaItem(icon: Icons.directions_walk, label: '$reachTime min walk'));
|
||||
}
|
||||
if (attractiveness != null) {
|
||||
items.add(_MetaItem(icon: Icons.star, label: 'Score $attractiveness'));
|
||||
}
|
||||
if (tagCount != null) {
|
||||
items.add(_MetaItem(icon: Icons.label, label: '$tagCount tags'));
|
||||
}
|
||||
if (isSecondary) {
|
||||
items.add(_MetaItem(icon: Icons.layers, label: 'Secondary stop'));
|
||||
}
|
||||
for (final tag in tags.where((tag) => tag.trim().isNotEmpty).take(4)) {
|
||||
items.add(_MetaItem(icon: Icons.local_offer, label: tag));
|
||||
}
|
||||
if (websiteLabel != null && websiteLabel.isNotEmpty && onOpenWebsite != null) {
|
||||
items.add(_MetaItem(icon: Icons.link, label: 'Website', onTap: onOpenWebsite));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
class _LandmarkMedia extends StatelessWidget {
|
||||
const _LandmarkMedia({required this.landmark, required this.position, required this.type});
|
||||
|
||||
final Landmark landmark;
|
||||
final int position;
|
||||
final LandmarkTypeEnum type;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const width = 116.0;
|
||||
const mediaHeight = 140.0;
|
||||
final label = _typeLabel(type);
|
||||
final icon = _typeIcon(type);
|
||||
|
||||
return SizedBox(
|
||||
width: width,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: mediaHeight,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(child: _buildMedia()),
|
||||
Positioned(
|
||||
left: 8,
|
||||
bottom: 8,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.65), borderRadius: BorderRadius.circular(18)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: Colors.white),
|
||||
const SizedBox(width: 4),
|
||||
Text(label, style: const TextStyle(color: Colors.white, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
padding: const EdgeInsets.symmetric(vertical: 5),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'#$position',
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMedia() {
|
||||
if (landmark.imageUrl != null && landmark.imageUrl!.isNotEmpty) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: landmark.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => const Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
errorWidget: (context, url, error) => _placeholder(),
|
||||
);
|
||||
}
|
||||
return _placeholder();
|
||||
}
|
||||
|
||||
Widget _placeholder() {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFF9B208), Color(0xFFE72E77)]),
|
||||
),
|
||||
child: const Center(child: Icon(Icons.photo, color: Colors.white, size: 32)),
|
||||
);
|
||||
}
|
||||
|
||||
String _typeLabel(LandmarkTypeEnum type) {
|
||||
switch (type) {
|
||||
case LandmarkTypeEnum.start:
|
||||
return 'Start';
|
||||
case LandmarkTypeEnum.finish:
|
||||
return 'Finish';
|
||||
case LandmarkTypeEnum.shopping:
|
||||
return 'Shopping';
|
||||
case LandmarkTypeEnum.nature:
|
||||
return 'Nature';
|
||||
case LandmarkTypeEnum.sightseeing:
|
||||
return 'Sight';
|
||||
}
|
||||
}
|
||||
|
||||
IconData _typeIcon(LandmarkTypeEnum type) {
|
||||
switch (type) {
|
||||
case LandmarkTypeEnum.start:
|
||||
return Icons.flag;
|
||||
case LandmarkTypeEnum.finish:
|
||||
return Icons.flag_circle;
|
||||
case LandmarkTypeEnum.shopping:
|
||||
return Icons.shopping_bag;
|
||||
case LandmarkTypeEnum.nature:
|
||||
return Icons.park;
|
||||
case LandmarkTypeEnum.sightseeing:
|
||||
return Icons.place;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _MetaScroller extends StatelessWidget {
|
||||
const _MetaScroller({required this.items});
|
||||
|
||||
final List<_MetaItem> items;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 34,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
final chip = Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(item.icon, size: 14, color: Colors.grey.shade800),
|
||||
const SizedBox(width: 4),
|
||||
Text(item.label, style: const TextStyle(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (item.onTap == null) return chip;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(borderRadius: BorderRadius.circular(14), onTap: item.onTap, child: chip),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetaItem {
|
||||
const _MetaItem({required this.icon, required this.label, this.onTap});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:anyway/domain/entities/landmark.dart';
|
||||
|
||||
class TripStepBetweenLandmarks extends StatelessWidget {
|
||||
const TripStepBetweenLandmarks({super.key, required this.current, required this.next, this.onRequestDirections});
|
||||
|
||||
final Landmark current;
|
||||
final Landmark next;
|
||||
final VoidCallback? onRequestDirections;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final minutes = current.timeToReachNextMinutes;
|
||||
final text = minutes == null ? 'Continue to ${next.name}' : '$minutes min to ${next.name}';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.directions_walk, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(text, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
),
|
||||
if (onRequestDirections != null) TextButton.icon(onPressed: onRequestDirections, icon: const Icon(Icons.navigation), label: const Text('Navigate')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
185
frontend/lib/presentation/widgets/trip_details_panel.dart
Normal file
185
frontend/lib/presentation/widgets/trip_details_panel.dart
Normal file
@@ -0,0 +1,185 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:map_launcher/map_launcher.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import 'package:anyway/domain/entities/landmark.dart';
|
||||
import 'package:anyway/domain/entities/trip.dart';
|
||||
import 'package:anyway/presentation/utils/trip_location_utils.dart';
|
||||
import 'package:anyway/presentation/widgets/trip_details/trip_hero_header.dart';
|
||||
import 'package:anyway/presentation/widgets/trip_details/trip_hero_meta_card.dart';
|
||||
import 'package:anyway/presentation/widgets/trip_details/trip_landmark_card.dart';
|
||||
import 'package:anyway/presentation/widgets/trip_details/trip_step_between_landmarks.dart';
|
||||
|
||||
class TripDetailsPanel extends StatefulWidget {
|
||||
const TripDetailsPanel({super.key, required this.trip, required this.controller, this.onLandmarkUpdated});
|
||||
|
||||
final Trip trip;
|
||||
final ScrollController controller;
|
||||
final VoidCallback? onLandmarkUpdated;
|
||||
|
||||
@override
|
||||
State<TripDetailsPanel> createState() => _TripDetailsPanelState();
|
||||
}
|
||||
|
||||
class _TripDetailsPanelState extends State<TripDetailsPanel> {
|
||||
late Future<TripLocaleInfo> _localeFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_localeFuture = TripLocationUtils.resolveLocaleInfo(widget.trip);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TripDetailsPanel oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.trip.uuid != widget.trip.uuid) {
|
||||
_localeFuture = TripLocationUtils.resolveLocaleInfo(widget.trip);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final landmarks = widget.trip.landmarks;
|
||||
if (landmarks.isEmpty) {
|
||||
return const Center(child: Text('No landmarks in this trip yet.'));
|
||||
}
|
||||
|
||||
return CustomScrollView(
|
||||
controller: widget.controller,
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
width: 42,
|
||||
height: 5,
|
||||
decoration: BoxDecoration(color: Colors.grey.shade300, borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: FutureBuilder<TripLocaleInfo>(
|
||||
future: _localeFuture,
|
||||
builder: (context, snapshot) {
|
||||
final info = snapshot.data;
|
||||
final isLoading = snapshot.connectionState == ConnectionState.waiting;
|
||||
final subtitle = isLoading ? 'Pinpointing your destination...' : 'Here is a recommended route for your trip in ${info?.cityName ?? 'your area'}.';
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TripHeroHeader(localeInfo: info),
|
||||
const SizedBox(height: 10),
|
||||
TripHeroMetaCard(
|
||||
subtitle: subtitle,
|
||||
totalStops: widget.trip.landmarks.length,
|
||||
totalMinutes: widget.trip.totalTimeMinutes,
|
||||
countryName: info?.countryName,
|
||||
isLoading: isLoading,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 32),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final landmark = landmarks[index];
|
||||
return Column(
|
||||
children: [
|
||||
TripLandmarkCard(
|
||||
landmark: landmark,
|
||||
position: index + 1,
|
||||
onToggleVisited: () => _toggleVisited(landmark),
|
||||
onOpenWebsite: landmark.websiteUrl == null ? null : () => _openWebsite(landmark.websiteUrl!),
|
||||
),
|
||||
if (index < landmarks.length - 1)
|
||||
TripStepBetweenLandmarks(current: landmark, next: landmarks[index + 1], onRequestDirections: () => _showNavigationOptions(context, landmark, landmarks[index + 1])),
|
||||
],
|
||||
);
|
||||
}, childCount: landmarks.length),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _toggleVisited(Landmark landmark) {
|
||||
setState(() => landmark.isVisited = !landmark.isVisited);
|
||||
widget.onLandmarkUpdated?.call();
|
||||
}
|
||||
|
||||
Future<void> _openWebsite(String url) async {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null) return;
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
|
||||
Future<void> _showNavigationOptions(BuildContext context, Landmark current, Landmark next) async {
|
||||
if (!_hasValidLocation(current) || !_hasValidLocation(next)) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Coordinates unavailable for directions.')));
|
||||
return;
|
||||
}
|
||||
|
||||
List<AvailableMap> availableMaps = [];
|
||||
try {
|
||||
availableMaps = await MapLauncher.installedMaps;
|
||||
} catch (error) {
|
||||
debugPrint('Unable to load maps: $error');
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (availableMaps.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('No navigation apps detected.')));
|
||||
return;
|
||||
}
|
||||
|
||||
final origin = Coords(current.location[0], current.location[1]);
|
||||
final destination = Coords(next.location[0], next.location[1]);
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (sheetContext) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Navigate to ${next.name}', style: Theme.of(sheetContext).textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text('Choose your preferred maps app.', style: Theme.of(sheetContext).textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final map in availableMaps)
|
||||
ListTile(
|
||||
leading: ClipRRect(borderRadius: BorderRadius.circular(8), child: SvgPicture.asset(map.icon, height: 30, width: 30)),
|
||||
title: Text(map.mapName),
|
||||
onTap: () async {
|
||||
await map.showDirections(origin: origin, originTitle: current.name, destination: destination, destinationTitle: next.name, directionsMode: DirectionsMode.walking);
|
||||
if (sheetContext.mounted) Navigator.of(sheetContext).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bool _hasValidLocation(Landmark landmark) => landmark.location.length >= 2;
|
||||
}
|
||||
221
frontend/lib/presentation/widgets/trip_map.dart
Normal file
221
frontend/lib/presentation/widgets/trip_map.dart
Normal file
@@ -0,0 +1,221 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_maps_flutter/google_maps_flutter.dart';
|
||||
import 'package:widget_to_marker/widget_to_marker.dart';
|
||||
|
||||
import 'package:anyway/core/constants.dart';
|
||||
import 'package:anyway/domain/entities/landmark.dart';
|
||||
import 'package:anyway/domain/entities/trip.dart';
|
||||
import 'package:anyway/presentation/utils/trip_location_utils.dart';
|
||||
import 'package:anyway/presentation/widgets/trip_marker_graphic.dart';
|
||||
|
||||
class TripMap extends StatefulWidget {
|
||||
const TripMap({
|
||||
super.key,
|
||||
required this.trip,
|
||||
this.showRoute = false,
|
||||
this.interactive = false,
|
||||
this.height,
|
||||
this.borderRadius = 12,
|
||||
this.enableMyLocation = false,
|
||||
});
|
||||
|
||||
final Trip trip;
|
||||
final bool showRoute;
|
||||
final bool interactive;
|
||||
final double? height;
|
||||
final double borderRadius;
|
||||
final bool enableMyLocation;
|
||||
|
||||
@override
|
||||
State<TripMap> createState() => _TripMapState();
|
||||
}
|
||||
|
||||
class _TripMapState extends State<TripMap> {
|
||||
GoogleMapController? _controller;
|
||||
LatLng? _startLatLng;
|
||||
Set<Marker> _markers = const {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startLatLng = _extractStartLatLng();
|
||||
_refreshMarkers();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TripMap oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.trip.uuid != widget.trip.uuid) {
|
||||
_startLatLng = _extractStartLatLng();
|
||||
_animateToStart();
|
||||
}
|
||||
if (oldWidget.trip.landmarks != widget.trip.landmarks ||
|
||||
oldWidget.showRoute != widget.showRoute ||
|
||||
oldWidget.interactive != widget.interactive) {
|
||||
_startLatLng = _extractStartLatLng();
|
||||
}
|
||||
_refreshMarkers();
|
||||
}
|
||||
|
||||
LatLng? _extractStartLatLng() {
|
||||
final coords = TripLocationUtils.startCoordinates(widget.trip);
|
||||
if (coords == null) {
|
||||
return null;
|
||||
}
|
||||
return LatLng(coords[0], coords[1]);
|
||||
}
|
||||
|
||||
void _animateToStart() {
|
||||
// TODO - required?
|
||||
if (_controller == null || _startLatLng == null) {
|
||||
return;
|
||||
}
|
||||
_controller!.animateCamera(CameraUpdate.newLatLng(_startLatLng!));
|
||||
}
|
||||
|
||||
Future<void> _refreshMarkers() async {
|
||||
if (_startLatLng == null) {
|
||||
setState(() => _markers = const {});
|
||||
return;
|
||||
}
|
||||
|
||||
final targets = widget.showRoute
|
||||
? widget.trip.landmarks
|
||||
: widget.trip.landmarks.isEmpty
|
||||
? const <Landmark>[]
|
||||
: <Landmark>[widget.trip.landmarks.first];
|
||||
|
||||
if (targets.isEmpty) {
|
||||
setState(() => _markers = const {});
|
||||
return;
|
||||
}
|
||||
|
||||
final markerSet = <Marker>{};
|
||||
for (var i = 0; i < targets.length; i++) {
|
||||
final landmark = targets[i];
|
||||
final latLng = _latLngFromLandmark(landmark);
|
||||
if (latLng == null) continue;
|
||||
final descriptor = await TripMarkerGraphic(
|
||||
landmark: landmark,
|
||||
position: i + 1,
|
||||
compact: !widget.showRoute,
|
||||
).toBitmapDescriptor(
|
||||
// use sizes based on font size to keep markers consistent:
|
||||
imageSize: Size(500, 500),
|
||||
);
|
||||
markerSet.add(
|
||||
Marker(
|
||||
markerId: MarkerId('landmark-${landmark.uuid}'),
|
||||
// since the marker is a cirlce (not a pin), we center it
|
||||
anchor: const Offset(0, 0),
|
||||
|
||||
position: latLng,
|
||||
icon: descriptor,
|
||||
// TODO - don't use info window but bind taps to the bottom panel
|
||||
// infoWindow: InfoWindow(title: landmark.name),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _markers = markerSet);
|
||||
}
|
||||
|
||||
LatLng? _latLngFromLandmark(Landmark landmark) {
|
||||
if (landmark.location.length < 2) {
|
||||
return null;
|
||||
}
|
||||
return LatLng(landmark.location[0], landmark.location[1]);
|
||||
}
|
||||
|
||||
Set<Polyline> _buildPolylines() {
|
||||
if (!widget.showRoute) {
|
||||
return const <Polyline>{};
|
||||
}
|
||||
final points = widget.trip.landmarks;
|
||||
if (points.length < 2) {
|
||||
return const <Polyline>{};
|
||||
}
|
||||
|
||||
final polylines = <Polyline>{};
|
||||
for (var i = 0; i < points.length - 1; i++) {
|
||||
final current = points[i];
|
||||
final next = points[i + 1];
|
||||
if (current.location.length < 2 || next.location.length < 2) {
|
||||
continue;
|
||||
}
|
||||
polylines.add(
|
||||
Polyline(
|
||||
polylineId: PolylineId('segment-${current.uuid}-${next.uuid}'),
|
||||
points: [
|
||||
LatLng(current.location[0], current.location[1]),
|
||||
LatLng(next.location[0], next.location[1]),
|
||||
],
|
||||
width: 4,
|
||||
color: points[i].isVisited && next.isVisited
|
||||
? Colors.grey
|
||||
: PRIMARY_COLOR,
|
||||
),
|
||||
);
|
||||
}
|
||||
return polylines;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_startLatLng == null) {
|
||||
return Container(
|
||||
height: widget.height,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Text('No landmarks available yet'),
|
||||
);
|
||||
}
|
||||
|
||||
final map = GoogleMap(
|
||||
key: ValueKey(
|
||||
'trip-map-${widget.trip.uuid}-${widget.showRoute}-${widget.interactive}',
|
||||
),
|
||||
onMapCreated: (controller) {
|
||||
_controller = controller;
|
||||
_animateToStart();
|
||||
},
|
||||
initialCameraPosition: CameraPosition(
|
||||
target: _startLatLng!,
|
||||
zoom: widget.interactive ? 12.5 : 13.5,
|
||||
),
|
||||
markers: _markers,
|
||||
polylines: _buildPolylines(),
|
||||
mapToolbarEnabled: widget.interactive,
|
||||
zoomControlsEnabled: widget.interactive,
|
||||
zoomGesturesEnabled: widget.interactive,
|
||||
scrollGesturesEnabled: widget.interactive,
|
||||
rotateGesturesEnabled: widget.interactive,
|
||||
tiltGesturesEnabled: widget.interactive,
|
||||
liteModeEnabled: !widget.interactive,
|
||||
myLocationEnabled: widget.enableMyLocation && widget.interactive,
|
||||
myLocationButtonEnabled: widget.enableMyLocation && widget.interactive,
|
||||
compassEnabled: widget.interactive,
|
||||
cloudMapId: MAP_ID,
|
||||
);
|
||||
|
||||
Widget decoratedMap = map;
|
||||
|
||||
if (widget.height != null) {
|
||||
decoratedMap = SizedBox(height: widget.height, child: decoratedMap);
|
||||
}
|
||||
|
||||
if (widget.borderRadius > 0) {
|
||||
decoratedMap = ClipRRect(
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||
child: decoratedMap,
|
||||
);
|
||||
}
|
||||
|
||||
return decoratedMap;
|
||||
}
|
||||
}
|
||||
|
||||
99
frontend/lib/presentation/widgets/trip_marker_graphic.dart
Normal file
99
frontend/lib/presentation/widgets/trip_marker_graphic.dart
Normal file
@@ -0,0 +1,99 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:anyway/core/constants.dart';
|
||||
import 'package:anyway/domain/entities/landmark.dart';
|
||||
import 'package:anyway/domain/entities/landmark_type.dart';
|
||||
|
||||
class TripMarkerGraphic extends StatelessWidget {
|
||||
const TripMarkerGraphic({
|
||||
super.key,
|
||||
required this.landmark,
|
||||
required this.position,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
final Landmark landmark;
|
||||
final int position;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final gradient = landmark.isVisited
|
||||
? const LinearGradient(colors: [Colors.grey, Colors.white])
|
||||
: APP_GRADIENT;
|
||||
final showPosition = landmark.type.type != LandmarkTypeEnum.start &&
|
||||
landmark.type.type != LandmarkTypeEnum.finish;
|
||||
final markerDiameter = compact ? 68.0 : 84.0;
|
||||
final borderWidth = compact ? 3.0 : 4.5;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: SizedBox(
|
||||
width: markerDiameter,
|
||||
height: markerDiameter,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: gradient,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.black87, width: borderWidth),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
blurRadius: 6,
|
||||
color: Colors.black26,
|
||||
offset: Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: EdgeInsets.all(compact ? 8 : 10),
|
||||
child: Icon(
|
||||
_iconForType(landmark.type.type),
|
||||
color: Colors.black87,
|
||||
size: compact ? 30 : 38,
|
||||
),
|
||||
),
|
||||
if (showPosition)
|
||||
Positioned(
|
||||
top: -2,
|
||||
right: -2,
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(compact ? 4 : 5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.black26),
|
||||
),
|
||||
child: Text(
|
||||
position.toString(),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: compact ? 12 : 14,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// TODO - should this be a landmark property?
|
||||
IconData _iconForType(LandmarkTypeEnum type) {
|
||||
switch (type) {
|
||||
case LandmarkTypeEnum.start:
|
||||
return Icons.flag;
|
||||
case LandmarkTypeEnum.finish:
|
||||
return Icons.flag_circle;
|
||||
case LandmarkTypeEnum.shopping:
|
||||
return Icons.shopping_bag;
|
||||
case LandmarkTypeEnum.nature:
|
||||
return Icons.park;
|
||||
case LandmarkTypeEnum.sightseeing:
|
||||
return Icons.place;
|
||||
}
|
||||
}
|
||||
}
|
||||
95
frontend/lib/presentation/widgets/trip_summary_card.dart
Normal file
95
frontend/lib/presentation/widgets/trip_summary_card.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:anyway/domain/entities/trip.dart';
|
||||
import 'package:anyway/presentation/utils/trip_location_utils.dart';
|
||||
import 'package:anyway/presentation/widgets/trip_map.dart';
|
||||
|
||||
class TripSummaryCard extends StatefulWidget {
|
||||
const TripSummaryCard({super.key, required this.trip, required this.onTap});
|
||||
|
||||
final Trip trip;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<TripSummaryCard> createState() => _TripSummaryCardState();
|
||||
}
|
||||
|
||||
class _TripSummaryCardState extends State<TripSummaryCard> {
|
||||
late Future<String> _cityFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cityFuture = TripLocationUtils.resolveCityName(widget.trip);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TripSummaryCard oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.trip.uuid != widget.trip.uuid) {
|
||||
_cityFuture = TripLocationUtils.resolveCityName(widget.trip);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final landmarksCount = widget.trip.landmarks.length;
|
||||
final startCoords = TripLocationUtils.startCoordinates(widget.trip);
|
||||
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TripMap(
|
||||
trip: widget.trip,
|
||||
showRoute: false,
|
||||
interactive: false,
|
||||
height: 180,
|
||||
borderRadius: 0,
|
||||
),
|
||||
// TODO - a more useful information to include will be the duration and the time of creation. But we are not there yet.
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: FutureBuilder<String>(
|
||||
future: _cityFuture,
|
||||
builder: (context, snapshot) {
|
||||
final title = snapshot.data ?? 'Trip ${widget.trip.uuid}';
|
||||
return Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
// TODO - start should be more descriptive. or omitted
|
||||
child: Text(
|
||||
startCoords == null
|
||||
? 'Start: unknown'
|
||||
: 'Start: ${startCoords[0].toStringAsFixed(4)}, '
|
||||
'${startCoords[1].toStringAsFixed(4)}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.route, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Text('$landmarksCount stops'),
|
||||
const Spacer(),
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user