4 Commits

105 changed files with 6376 additions and 443 deletions

View File

@@ -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
View File

@@ -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"
}
},
{

View File

@@ -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."
'';
}

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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
}

View 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>();

View 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,
));
}

View File

View 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);
}
}

View 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;
}
}

View 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: []),
);
}

View 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

View 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,
};

View 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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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
```

View 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;
}

View 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

View 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,
};

View 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);
}

View 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

View 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,
};

View 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]!;
}

View 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

View 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',
};

View 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);
}

View 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

View 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,
};

View 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);
}

View 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

View 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,
};

View File

@@ -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);
}

View File

@@ -0,0 +1,6 @@
import 'package:anyway/domain/entities/preferences.dart';
abstract class PreferencesRepository {
Future<Preferences> getPreferences();
Future<void> savePreferences(Preferences preferences);
}

View 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);
}

View File

@@ -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,
);
}
}

View File

@@ -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);
}
);
}
}

View File

@@ -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],
);
);

View File

@@ -20,7 +20,7 @@ class _CurrentTripErrorMessageState extends State<CurrentTripErrorMessage> {
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
const Text(
"😢",
style: TextStyle(
fontSize: 40,

View File

@@ -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> {
)
);
}
}

View File

@@ -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)
);
}
}

View File

@@ -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();

View 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,
),
),
),
],
),
);
}
}

View File

@@ -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();
}

View 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),
],
);
}
}

View File

@@ -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)),

View File

@@ -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,

View File

@@ -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(

View File

@@ -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 {
),
);
}
}
}

View File

@@ -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!'),
);
}
);
}

View File

@@ -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

View File

@@ -106,4 +106,4 @@ class _NewTripMapState extends State<NewTripMap> {
myLocationEnabled: useLocation,
);
}
}
}

View File

@@ -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')
);
);
}
);
}

View File

@@ -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();
}
},
)
);

View File

@@ -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(

View 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,
)
);
}
}

View File

@@ -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.

View File

@@ -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."

View File

@@ -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(() {

View File

@@ -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 {
)
)
);
}
}

View File

@@ -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));

View File

@@ -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
};
}
}
}

View File

@@ -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();
}

View File

@@ -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;
}
}

View File

@@ -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();
}
},
);
}
}

View 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),
],
),
);
}
}

View 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')),
);
}
}

View 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),
),
);
}
}

View File

@@ -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
});
}
}

View 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'),
),
);
}
}

View 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',
),
),
);
}
}

View 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),
);
},
),
);
}
}

View 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);
});

View 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);

View File

@@ -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);
}
}

View 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);

View 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';
}

View 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,
),
),
),
],
),
],
),
);
}
}

View 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,
),
),
]
),
);
}
}

View File

@@ -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),
),
);
}
}

View File

@@ -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';
}

View File

@@ -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;
}

View File

@@ -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')),
],
),
),
),
);
}
}

View 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;
}

View 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;
}
}

View 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;
}
}
}

View 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