Swipe able cards (a first draft)

master
Rafael 2024-05-24 00:30:08 +02:00
parent 40581f66da
commit 95b1df39da
8 changed files with 419 additions and 15 deletions

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../pages/home_page.dart'; import '../pages/home_page.dart';
import '../pages/user_data_page.dart'; import '../pages/user_data_page.dart';
import '../pages/settings_page.dart'; import '../pages/settings_page.dart';
import '../pages/user_profile_page.dart';
import '../services/auth/auth_service.dart'; import '../services/auth/auth_service.dart';
import 'feedback_dialog.dart'; import 'feedback_dialog.dart';
@ -57,7 +58,18 @@ class MyDrawer extends StatelessWidget {
child: ListTile( child: ListTile(
title: const Text("Find Matches"), title: const Text("Find Matches"),
leading: const Icon(Icons.person_search), leading: const Icon(Icons.person_search),
onTap: () {}, // TODO onTap: () {
// pop the drawer
Navigator.pop(context);
// Navigate to UserProfile
Navigator.push(
context,
MaterialPageRoute(
builder: (BuildContext context) =>
const UserProfilePage(),
),
);
},
), ),
), ),

View File

@ -14,6 +14,20 @@ class Constants {
static const String dbDocMainLocation = 'main'; static const String dbDocMainLocation = 'main';
static const String dbDocSecondLocation = 'secondary'; static const String dbDocSecondLocation = 'secondary';
static const String dbFieldLocationLatitude = 'latitude';
static const String dbFieldLocationLongitude = 'longitude';
static const String dbFieldLocationStreet = 'street';
static const String dbFieldLocationCountry = 'country';
static const String dbFieldLocationArea = 'administrativeArea';
static const String dbFieldLocationLocality = 'locality';
static const String dbFieldLocationSubLocality = 'subLocality';
static const String dbFieldLocationPostalCode = 'postalCode';
static const String dbFieldUsersID = 'uid';
static const String dbFieldUsersEmail = 'email';
static const String dbFieldUsersName = 'name';
static const String dbFieldUsersFirstName = 'firstname';
static const String dbFieldUsersLastName = 'lastname';
static const String dbFieldUsersGender = 'gender'; static const String dbFieldUsersGender = 'gender';
static const String dbFieldUsersYearBorn = 'born'; static const String dbFieldUsersYearBorn = 'born';
static const String dbFieldUsersSkills = 'skills'; static const String dbFieldUsersSkills = 'skills';

View File

@ -1,3 +1,4 @@
import '../constants.dart';
import '../helper.dart'; import '../helper.dart';
class MyLocation { class MyLocation {
@ -28,14 +29,14 @@ class MyLocation {
// convert to a map // convert to a map
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { return {
'street': street, Constants.dbFieldLocationStreet: street,
'country': country, Constants.dbFieldLocationCountry: country,
'administrativeArea': administrativeArea, Constants.dbFieldLocationArea: administrativeArea,
'locality': locality, Constants.dbFieldLocationLocality: locality,
'subLocality': subLocality, Constants.dbFieldLocationSubLocality: subLocality,
'postalCode': postalCode, Constants.dbFieldLocationPostalCode: postalCode,
'latitude': latitude, Constants.dbFieldLocationLatitude: latitude,
'longitude': longitude, Constants.dbFieldLocationLongitude: longitude,
}; };
} }
@ -51,8 +52,8 @@ class MyLocation {
} }
} }
@override
/// Returns: locality, country /// Returns: locality, country
@override
String toString() { String toString() {
return '$locality, $country'; return '$locality, $country';
} }

View File

@ -0,0 +1,52 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import '../constants.dart';
import 'language.dart';
import 'location.dart';
class UserProfile {
final String id;
final String uid;
final String email;
final String name;
final String firstName;
final String lastName;
final String risk;
final List<String> skills;
final List<String> skillsSought;
final List<Language> languages;
final Map<String, MyLocation?> locations;
UserProfile({
required this.id,
required this.uid,
required this.email,
required this.name,
required this.firstName,
required this.lastName,
required this.risk,
required this.skills,
required this.skillsSought,
required this.languages,
required this.locations,
});
factory UserProfile.fromDocument(DocumentSnapshot doc) {
Map<String, dynamic> data = doc.data() as Map<String, dynamic>;
return UserProfile(
id: doc.id,
email: data[Constants.dbFieldUsersEmail] ?? '',
name: data[Constants.dbFieldUsersName] ?? '',
firstName: data[Constants.dbFieldUsersFirstName] ?? '',
lastName: data[Constants.dbFieldUsersLastName] ?? '',
uid: data[Constants.dbFieldUsersID] ?? '',
skills: List<String>.from(data[Constants.dbFieldUsersSkills] ?? []),
skillsSought:
List<String>.from(data[Constants.dbFieldUsersSkillsSought] ?? []),
risk: data[Constants.dbFieldUsersRiskTolerance] ?? '',
languages: [],
locations: {},
);
}
}

View File

@ -0,0 +1,308 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:swipable_stack/swipable_stack.dart';
import '../constants.dart';
import '../models/language.dart';
import '../models/location.dart';
import '../models/user_profile.dart';
class UserProfilePage extends StatefulWidget {
const UserProfilePage({super.key});
@override
UserProfilePageState createState() => UserProfilePageState();
}
class UserProfilePageState extends State<UserProfilePage> {
List<UserProfile> userProfiles = [];
late final SwipableStackController _controller;
void _listenController() => setState(() {});
@override
void initState() {
super.initState();
_controller = SwipableStackController()..addListener(_listenController);
_fetchUserProfiles();
}
@override
void dispose() {
_controller
..removeListener(_listenController)
..dispose();
super.dispose();
}
Future<void> _fetchUserProfiles() async {
final querySnapshot = await FirebaseFirestore.instance
.collection(Constants.dbCollectionUsers)
.get();
final users = await Future.wait(querySnapshot.docs.map((doc) async {
final languagesSnapshot =
await doc.reference.collection(Constants.dbCollectionLanguages).get();
final locationsSnapshot =
await doc.reference.collection(Constants.dbCollectionLocations).get();
final languages = languagesSnapshot.docs.map((doc) {
final data = doc.data();
return Language(
code: data['code'],
name: data['name'],
nativeName: data['nativeName'],
iconFile: data['iconFile'],
);
}).toList();
final mainDoc = locationsSnapshot.docs.firstWhereOrNull(
(doc) => doc.id == Constants.dbDocMainLocation,
);
final secondaryDoc = locationsSnapshot.docs.firstWhereOrNull(
(doc) => doc.id == Constants.dbDocSecondLocation,
);
final locations = {
Constants.dbDocMainLocation:
mainDoc != null ? _createLocationFromDoc(mainDoc.data()) : null,
Constants.dbDocSecondLocation: secondaryDoc != null
? _createLocationFromDoc(secondaryDoc.data())
: null,
};
final data = doc.data();
return UserProfile(
id: doc.id,
uid: data[Constants.dbFieldUsersID] ?? '',
email: data[Constants.dbFieldUsersEmail] ?? '',
name: data[Constants.dbFieldUsersName] ?? '',
firstName: data[Constants.dbFieldUsersFirstName] ?? '',
lastName: data[Constants.dbFieldUsersLastName] ?? '',
skills: List<String>.from(data[Constants.dbFieldUsersSkills] ?? []),
skillsSought:
List<String>.from(data[Constants.dbFieldUsersSkillsSought] ?? []),
risk: data[Constants.dbFieldUsersRiskTolerance] ?? '',
languages: languages,
locations: locations,
);
}).toList());
setState(() {
userProfiles = users;
});
}
MyLocation? _createLocationFromDoc(Map<String, dynamic>? data) {
if (data == null || data.isEmpty) return null;
return MyLocation(
street: data[Constants.dbFieldLocationStreet],
country: data[Constants.dbFieldLocationCountry],
administrativeArea: data[Constants.dbFieldLocationArea],
locality: data[Constants.dbFieldLocationLocality],
subLocality: data[Constants.dbFieldLocationSubLocality],
postalCode: data[Constants.dbFieldLocationPostalCode],
latitude: data[Constants.dbFieldLocationLatitude],
longitude: data[Constants.dbFieldLocationLongitude],
);
}
void _swipeLeft() {
_saveSwipeAction(userProfiles[_controller.currentIndex].id, 'dislike');
_controller.next(
swipeDirection: SwipeDirection.left, duration: Durations.extralong4);
}
void _swipeRight() {
_saveSwipeAction(userProfiles[_controller.currentIndex].id, 'like');
_controller.next(
swipeDirection: SwipeDirection.right, duration: Durations.extralong4);
}
void _skip() {
_controller.next(
swipeDirection: SwipeDirection.up, duration: Durations.extralong2);
}
void _saveSwipeAction(String userId, String action) {
/* FirebaseFirestore.instance.collection('swipes').add({
'userId': userId,
'action': action,
'timestamp': FieldValue.serverTimestamp(),
});*/
}
@override
Widget build(BuildContext context) {
if (userProfiles.isEmpty) {
return Scaffold(
appBar: AppBar(title: const Text('User Profiles')),
body: const Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(title: const Text('User Profiles')),
body: SafeArea(
top: false,
child: Stack(
children: [
Positioned.fill(
child: Padding(
padding: const EdgeInsets.all(8),
child: SwipableStack(
detectableSwipeDirections: const {
SwipeDirection.right,
SwipeDirection.left,
SwipeDirection.up,
},
controller: _controller,
stackClipBehaviour: Clip.none,
onSwipeCompleted: (index, direction) {
if (index >= userProfiles.length) {
setState(() {
_controller.currentIndex = 0; // again from the start
});
}
},
horizontalSwipeThreshold: 0.8,
verticalSwipeThreshold: 0.8,
builder: (context, properties) {
final userProfile =
userProfiles[properties.index % userProfiles.length];
return Container(
alignment: Alignment.center,
color: Colors.tealAccent,
child: Stack(
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(userProfile.name,
style: const TextStyle(fontSize: 24)),
Text(userProfile.email,
style: const TextStyle(fontSize: 24)),
const SizedBox(height: 8),
Text(
'Has skills and experience in: ${userProfile.skills.join(', ')}'),
Text(
'Seeks someone with skills in: ${userProfile.skillsSought.join(', ')}'),
Text('Risk type: ${userProfile.risk}'),
Text(
'Speaks: ${userProfile.languages.map((lang) => lang.name).join(', ')}'),
Text(
'Lives in: ${userProfile.locations['main']?.locality ?? 'N/A'}'),
Text(
'Second home: ${userProfile.locations['secondary']?.locality ?? 'N/A'}'),
],
),
),
),
),
if (properties.stackIndex == 0 &&
properties.direction != null)
CardOverlay(
swipeProgress: properties.swipeProgress,
direction: properties.direction!,
),
],
),
);
},
),
),
),
Positioned(
bottom: 16,
left: 0,
right: 0,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
FloatingActionButton(
tooltip: 'Undo last action',
shape: const CircleBorder(),
onPressed: () {
_controller.rewind(duration: Durations.extralong4);
},
child: const Icon(Icons.undo),
),
SizedBox(
width: 72,
height: 72,
child: FloatingActionButton(
shape: const CircleBorder(),
onPressed: _swipeLeft,
child:
const Icon(Icons.cancel, color: Colors.red, size: 64),
),
),
SizedBox(
width: 72,
height: 72,
child: FloatingActionButton(
shape: const CircleBorder(),
onPressed: _swipeRight,
child: const Icon(Icons.check_circle,
color: Colors.green, size: 64),
),
),
FloatingActionButton(
tooltip: 'Skip profile',
shape: const CircleBorder(),
onPressed: _skip,
child: const Icon(Icons.skip_next),
),
],
),
),
],
),
),
);
}
}
class CardOverlay extends StatelessWidget {
final double swipeProgress;
final SwipeDirection direction;
const CardOverlay({
super.key,
required this.swipeProgress,
required this.direction,
});
@override
Widget build(BuildContext context) {
return Positioned.fill(
bottom: 300,
child: Opacity(
opacity: swipeProgress.abs().clamp(0.0, 1.0),
child: Align(
alignment: direction == SwipeDirection.right
? Alignment.centerLeft
: (direction == SwipeDirection.left
? Alignment.centerRight
: Alignment.center),
child: Icon(
direction == SwipeDirection.right
? Icons.check_circle
: (direction == SwipeDirection.left
? Icons.cancel
: Icons.skip_next),
size: 100,
color: direction == SwipeDirection.right
? Colors.green
: (direction == SwipeDirection.left ? Colors.red : Colors.blue),
),
),
),
);
}
}

View File

@ -21,11 +21,11 @@ class UserService {
.doc(userCredential.user!.uid) .doc(userCredential.user!.uid)
.set( .set(
{ {
'uid': userCredential.user!.uid, Constants.dbFieldUsersID: userCredential.user!.uid,
'email': email, Constants.dbFieldUsersEmail: email,
'firstname': firstname, Constants.dbFieldUsersFirstName: firstname,
'lastname': lastname, Constants.dbFieldUsersLastName: lastname,
'name': fullName, Constants.dbFieldUsersName: fullName,
}, },
); );
} }

View File

@ -413,6 +413,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.0" version: "7.0.0"
sprung:
dependency: transitive
description:
name: sprung
sha256: "54322638f5e393d2b808175f7eadbaa4836a4425456e98d93c3d676dc56ebdf1"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
@ -437,6 +445,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.0" version: "1.2.0"
swipable_stack:
dependency: "direct main"
description:
name: swipable_stack
sha256: b04eef070455e868b68fdd5ae98f6718e561c877348acd017c07805f1439ef19
url: "https://pub.dev"
source: hosted
version: "2.0.0"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:

View File

@ -43,6 +43,7 @@ dependencies:
geolocator: ^11.0.0 geolocator: ^11.0.0
geocoding: ^3.0.0 geocoding: ^3.0.0
collection: ^1.18.0 collection: ^1.18.0
swipable_stack: ^2.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: