diff --git a/.gitignore b/.gitignore index 8eb0d7d..241cf6a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +android/app/.cxx + /target .env diff --git a/.sqlx/query-7fe22395d950df57715351d71db78ef912cd839142ef4ba3f16b908d829c00dc.json b/.sqlx/query-7fe22395d950df57715351d71db78ef912cd839142ef4ba3f16b908d829c00dc.json deleted file mode 100644 index 80ddc01..0000000 --- a/.sqlx/query-7fe22395d950df57715351d71db78ef912cd839142ef4ba3f16b908d829c00dc.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH user_tags AS (\n SELECT tag_name FROM UserTags WHERE user_id = $1\n),\nuser_likes AS (\n SELECT target_user_id FROM UserLikes WHERE user_id = $1\n),\nuser_dislikes AS (\n SELECT target_user_id FROM UserDislikes WHERE user_id = $1\n),\n-- Get user's tags with embeddings\nuser_tag_embeddings AS (\n SELECT t.name, t.embedding\n FROM Tags t\n JOIN UserTags ut ON t.name = ut.tag_name\n WHERE ut.user_id = $1 AND t.embedding IS NOT NULL\n),\n-- Get potential nemesis users' tags with embeddings\nnemesis_tag_embeddings AS (\n SELECT ut.user_id, t.name, t.embedding\n FROM Tags t\n JOIN UserTags ut ON t.name = ut.tag_name\n WHERE ut.user_id != $1 \n AND ut.user_id NOT IN (SELECT target_user_id FROM user_likes)\n AND ut.user_id NOT IN (SELECT target_user_id FROM user_dislikes)\n AND t.embedding IS NOT NULL\n),\n-- Calculate tag embedding similarity scores for each potential nemesis\ntag_similarity_scores AS (\n SELECT \n nte.user_id,\n -- For each user, calculate average similarity between their tags and opposite of user's tags\n -- Higher score = more opposite tags (better nemesis match)\n AVG(\n CASE \n WHEN ute.embedding IS NOT NULL AND nte.embedding IS NOT NULL THEN\n -- Invert user tag embedding for opposite comparison\n -- Scale to 0-1 range where 1 = perfect opposite\n (1 - ((ute.embedding <=> nte.embedding) / 2))\n ELSE 0.5\n END\n ) AS tag_embedding_score\n FROM \n nemesis_tag_embeddings nte\n CROSS JOIN \n user_tag_embeddings ute\n GROUP BY \n nte.user_id\n),\n-- Calculate combined score based on embedding similarity and tag overlap\nuser_scores AS (\n SELECT \n u.id,\n (\n -- 1. User embedding similarity component (50%)\n -- Higher score = better nemesis match (more opposite)\n CASE\n WHEN u.embedding IS NOT NULL THEN \n -- Compare with negative embedding to find semantic opposites\n -- Scale to 0-1 range where 1 = perfect nemesis\n (1 - (u.embedding <=> $4::vector) / 2)\n ELSE 0.5 -- Default middle value if no embedding\n END * 0.5 -- 50% weight for user embedding\n \n +\n \n -- 2. Tag embedding similarity component (30%)\n -- Use the tag similarity scores calculated above\n COALESCE((\n SELECT tag_embedding_score \n FROM tag_similarity_scores \n WHERE user_id = u.id\n ), 0.5) * 0.3 -- 30% weight for tag embeddings\n \n +\n \n -- 3. Tag overlap component (20%)\n -- Lower = more overlap, we want the opposite\n -- Count percentage of non-matching tags\n (1 - COALESCE((\n SELECT COUNT(*)::float \n FROM UserTags ut\n WHERE ut.user_id = u.id AND ut.tag_name IN (SELECT tag_name FROM user_tags)\n ), 0) / \n NULLIF((\n SELECT COUNT(*)::float \n FROM UserTags \n WHERE user_id = u.id\n ), 1)) * 0.2 -- 20% weight for tag name overlap\n ) AS nemesis_score\n FROM \n Users u\n WHERE \n u.id != $1\n AND u.id NOT IN (SELECT target_user_id FROM user_likes)\n AND u.id NOT IN (SELECT target_user_id FROM user_dislikes)\n)\n\nSELECT \n u.id, u.username, u.display_name, u.avatar_url as \"avatar_url: Url\", u.bio, u.created_at, u.updated_at, u.embedding as \"embedding: Vector\", \n COALESCE(us.nemesis_score, 0.5) AS compatibility_score\nFROM \n Users u\nLEFT JOIN \n user_scores us ON u.id = us.id\nWHERE \n u.id != $1\n AND u.id NOT IN (SELECT target_user_id FROM user_likes)\n AND u.id NOT IN (SELECT target_user_id FROM user_dislikes)\nORDER BY \n -- Order by nemesis score (highest first = most incompatible)\n compatibility_score DESC\nLIMIT $2\nOFFSET $3\n\n", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "username", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "display_name", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "avatar_url: Url", - "type_info": "Varchar" - }, - { - "ordinal": 4, - "name": "bio", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 6, - "name": "updated_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 7, - "name": "embedding: Vector", - "type_info": { - "Custom": { - "name": "vector", - "kind": "Simple" - } - } - }, - { - "ordinal": 8, - "name": "compatibility_score", - "type_info": "Float8" - } - ], - "parameters": { - "Left": [ - "Text", - "Int8", - "Int8", - { - "Custom": { - "name": "vector", - "kind": "Simple" - } - } - ] - }, - "nullable": [ - false, - false, - true, - false, - false, - false, - false, - true, - null - ] - }, - "hash": "7fe22395d950df57715351d71db78ef912cd839142ef4ba3f16b908d829c00dc" -} diff --git a/.sqlx/query-9bd9591a9e5426e2cc2c234b79714b9af9f1047f9d6b02ca3b3d583d2a0ef8a1.json b/.sqlx/query-9bd9591a9e5426e2cc2c234b79714b9af9f1047f9d6b02ca3b3d583d2a0ef8a1.json new file mode 100644 index 0000000..61dfb94 --- /dev/null +++ b/.sqlx/query-9bd9591a9e5426e2cc2c234b79714b9af9f1047f9d6b02ca3b3d583d2a0ef8a1.json @@ -0,0 +1,83 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH user_tags AS (\r\n SELECT tag_name FROM UserTags WHERE user_id = $1\r\n),\r\nuser_likes AS (\r\n SELECT target_user_id FROM UserLikes WHERE user_id = $1\r\n),\r\nuser_dislikes AS (\r\n SELECT target_user_id FROM UserDislikes WHERE user_id = $1\r\n),\r\n-- Get user's tags with embeddings\r\nuser_tag_embeddings AS (\r\n SELECT t.name, t.embedding\r\n FROM Tags t\r\n JOIN UserTags ut ON t.name = ut.tag_name\r\n WHERE ut.user_id = $1 AND t.embedding IS NOT NULL\r\n),\r\n-- Get potential nemesis users' tags with embeddings\r\nnemesis_tag_embeddings AS (\r\n SELECT ut.user_id, t.name, t.embedding\r\n FROM Tags t\r\n JOIN UserTags ut ON t.name = ut.tag_name\r\n WHERE ut.user_id != $1 \r\n AND ut.user_id NOT IN (SELECT target_user_id FROM user_likes)\r\n AND ut.user_id NOT IN (SELECT target_user_id FROM user_dislikes)\r\n AND t.embedding IS NOT NULL\r\n),\r\n-- Calculate tag embedding similarity scores for each potential nemesis\r\ntag_similarity_scores AS (\r\n SELECT \r\n nte.user_id,\r\n -- For each user, calculate average similarity between their tags and opposite of user's tags\r\n -- Higher score = more opposite tags (better nemesis match)\r\n AVG(\r\n CASE \r\n WHEN ute.embedding IS NOT NULL AND nte.embedding IS NOT NULL THEN\r\n -- Invert user tag embedding for opposite comparison\r\n -- Scale to 0-1 range where 1 = perfect opposite\r\n (1 - ((ute.embedding <=> nte.embedding) / 2))\r\n ELSE 0.5\r\n END\r\n ) AS tag_embedding_score\r\n FROM \r\n nemesis_tag_embeddings nte\r\n CROSS JOIN \r\n user_tag_embeddings ute\r\n GROUP BY \r\n nte.user_id\r\n),\r\n-- Calculate combined score based on embedding similarity and tag overlap\r\nuser_scores AS (\r\n SELECT \r\n u.id,\r\n (\r\n -- 1. User embedding similarity component (50%)\r\n -- Higher score = better nemesis match (more opposite)\r\n CASE\r\n WHEN u.embedding IS NOT NULL THEN \r\n -- Compare with negative embedding to find semantic opposites\r\n -- Scale to 0-1 range where 1 = perfect nemesis\r\n (1 - (u.embedding <=> $4::vector) / 2)\r\n ELSE 0.5 -- Default middle value if no embedding\r\n END * 0.5 -- 50% weight for user embedding\r\n \r\n +\r\n \r\n -- 2. Tag embedding similarity component (30%)\r\n -- Use the tag similarity scores calculated above\r\n COALESCE((\r\n SELECT tag_embedding_score \r\n FROM tag_similarity_scores \r\n WHERE user_id = u.id\r\n ), 0.5) * 0.3 -- 30% weight for tag embeddings\r\n \r\n +\r\n \r\n -- 3. Tag overlap component (20%)\r\n -- Lower = more overlap, we want the opposite\r\n -- Count percentage of non-matching tags\r\n (1 - COALESCE((\r\n SELECT COUNT(*)::float \r\n FROM UserTags ut\r\n WHERE ut.user_id = u.id AND ut.tag_name IN (SELECT tag_name FROM user_tags)\r\n ), 0) / \r\n NULLIF((\r\n SELECT COUNT(*)::float \r\n FROM UserTags \r\n WHERE user_id = u.id\r\n ), 1)) * 0.2 -- 20% weight for tag name overlap\r\n ) AS nemesis_score\r\n FROM \r\n Users u\r\n WHERE \r\n u.id != $1\r\n AND u.id NOT IN (SELECT target_user_id FROM user_likes)\r\n AND u.id NOT IN (SELECT target_user_id FROM user_dislikes)\r\n)\r\n\r\nSELECT \r\n u.id, u.username, u.display_name, u.avatar_url as \"avatar_url: Url\", u.bio, u.created_at, u.updated_at, u.embedding as \"embedding: Vector\", \r\n COALESCE(us.nemesis_score, 0.5) AS compatibility_score\r\nFROM \r\n Users u\r\nLEFT JOIN \r\n user_scores us ON u.id = us.id\r\nWHERE \r\n u.id != $1\r\n AND u.id NOT IN (SELECT target_user_id FROM user_likes)\r\n AND u.id NOT IN (SELECT target_user_id FROM user_dislikes)\r\nORDER BY \r\n -- Order by nemesis score (highest first = most incompatible)\r\n compatibility_score DESC\r\nLIMIT $2\r\nOFFSET $3\r\n\r\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "avatar_url: Url", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "bio", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "embedding: Vector", + "type_info": { + "Custom": { + "name": "vector", + "kind": "Simple" + } + } + }, + { + "ordinal": 8, + "name": "compatibility_score", + "type_info": "Float8" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8", + "Int8", + { + "Custom": { + "name": "vector", + "kind": "Simple" + } + } + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + false, + false, + true, + null + ] + }, + "hash": "9bd9591a9e5426e2cc2c234b79714b9af9f1047f9d6b02ca3b3d583d2a0ef8a1" +} diff --git a/android/settings.gradle b/android/settings.gradle index 9759a22..5914426 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -18,7 +18,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.2.1" apply false // START: FlutterFire Configuration id "com.google.gms.google-services" version "4.3.15" apply false // END: FlutterFire Configuration diff --git a/lib/api.dart b/lib/api.dart index f9cde01..1befefd 100644 --- a/lib/api.dart +++ b/lib/api.dart @@ -1,4 +1,3 @@ - import 'log.dart' as log; import 'package:http/http.dart' as http; @@ -13,68 +12,98 @@ import './profile.dart'; //const port = 8000; //const host = "10.0.2.2:8000"; const host = "archenemy-zusg.shuttle.app"; +const prefix = "/api/v1"; const https = true; Profile? _profileFromMap(dynamic map) { - if (map case { - // Rare dart W, this is great - 'id': String id, - 'username': String username, - 'display_name': String? displayName, - 'avatar_url': String avatarUrl, - 'bio': String bio, - /*'embedding': dynamic _, - 'created_at': dynamic _, - 'updated_at': dynamic _*/ - }) { - return Profile( - id: id, - username: username, - displayName: displayName ?? "", - avatarUrl: avatarUrl, - bio: bio, - tags: map["tags"] is List ? map["tags"] : null - ); - } else { - log.warning("Failed to decode profile: $map"); - return null; - } + if (map case { + // Rare dart W, this is great + 'id': String id, + 'username': String username, + 'display_name': String? displayName, + 'avatar_url': String avatarUrl, + 'bio': String bio, + /*'embedding': dynamic _, + 'created_at': dynamic _, + 'updated_at': dynamic _*/ + }) { + log.info(map); + return Profile( + id: id, + username: username, + displayName: displayName ?? "", + avatarUrl: avatarUrl, + bio: bio, + tags: map["tags"] is List ? map["tags"] : null + ); + } else { + log.warning("Failed to decode profile: $map"); + return null; + } } + +List _listFromJson(String raw, T? Function(dynamic) converter) { + dynamic list = json.decode(raw); + List output = []; + if (list is List) { + for (final item in list) { + final decoded = converter(item); + if (decoded != null) { + output.add(decoded); + } + } + } + return output; +} Profile? _profileFromJson(String raw) { - return _profileFromMap(json.decode(raw)); -} -List? _profilesFromJson(String raw) { - var list = json.decode(raw); - List decoded = []; - if (list is List) { - for (final raw in list) { - if (_profileFromMap(raw) case Profile profile) { - decoded.add(profile); - } - } - } - return decoded; -} -String _profileToJson(Profile profile) { - return json.encode({ - 'id': profile.id, - 'username': profile.username, - 'display_name': profile.displayName, - 'avatar_url': profile.avatarUrl, - 'bio': profile.bio, - 'tags': profile.tags - }); + return _profileFromMap(json.decode(raw)); +} +List _profilesFromJson(String raw) { + return _listFromJson(raw, _profileFromMap); +} + +String? _tagFromMap(dynamic map) { + if (map case { 'tag_name': String name }) { + return name; + } + return null; } +List _tagsFromJson(String raw) { + return _listFromJson(raw, _tagFromMap); +} + +/*String _profileToJson(Profile profile) { + return json.encode({ + 'id': profile.id, + 'username': profile.username, + 'display_name': profile.displayName, + 'avatar_url': profile.avatarUrl, + 'bio': profile.bio, + 'tags': profile.tags + }); +}*/ Future getMyProfile() { - return _get("/user/me", - ok: (res) => _profileFromJson(res.body), + return _get("/user/me", + ok: (res) => _profileFromJson(res.body), err: (_) => null ); } + +Future> _getUserTags(String id) async { + return _get>("/user/$id/tags", + ok: (res) => _tagsFromJson(res.body), + err: (_) => [] + ); +} +Future> getProfileTags(Profile profile) async { + profile.tags ??= await _getUserTags(profile.id); + return profile.tags!; +} + Future patchMyProfile(Profile profile) { - return _req(http.patch, "/user/me", + return _req(http.put, "/user/me", headers: { "Content-Type": "application/json", }, @@ -82,122 +111,132 @@ Future patchMyProfile(Profile profile) { "username": profile.username, "display_name": profile.displayName, "avatar_url": profile.avatarUrl, - "bio": profile.bio + "bio": profile.bio, }), ok: (res) => true, err: (_) => false ); } +Future> getMatches() async { + return _get("/user/me/likes", + ok: (res) => _profilesFromJson(res.body), + err: (_) => [] + ); +} + + List _exploreProfiles = []; Future?>? _exploreRequest; -Future getNextExploreProfile() async { - if (_exploreProfiles.length <= 3) { - _addExploreProfiles(); // don't await - } - - if (_exploreProfiles.isEmpty) { - await _exploreRequest; // need to wait now - } - - return _exploreProfiles.firstOrNull; -} -Future _addExploreProfiles({ int paginationOffset = 0 }) async { - if (_exploreRequest == null) { - _exploreRequest = _getExploreProfiles( - paginationOffset: paginationOffset - ); - final profiles = await _exploreRequest; - _exploreRequest = null; - if (profiles != null) { - _exploreProfiles.addAll(profiles); - } - } -} -Future?> _getExploreProfiles({ int paginationOffset = 0 }) { - return _get("/nemeses/discover", - params: { "offset": paginationOffset.toString() }, - ok: (res) => _profilesFromJson(res.body), - err: (_) => null, - ); + +Future> getExploreProfiles() async { + if (_exploreProfiles.length <= 3) { + _addExploreProfiles(); // don't await + } + + if (_exploreProfiles.isEmpty) { + await _exploreRequest; // need to wait now + } + + return _exploreProfiles; +} + +Future _addExploreProfiles({int paginationOffset = 0}) async { + if (_exploreRequest == null) { + _exploreRequest = _getExploreProfiles(paginationOffset: paginationOffset); + final profiles = await _exploreRequest; + _exploreRequest = null; + if (profiles != null) { + log.info("Retrieved explore profiles: $profiles"); + _exploreProfiles.addAll(profiles); + } + } } + +Future?> _getExploreProfiles({int paginationOffset = 0}) { + return _get( + "/nemeses", + params: {"offset": paginationOffset.toString()}, + ok: (res) => _profilesFromJson(res.body), + err: (_) => null, + ); +} + Future postLike(String nemesisId) { - return _req(http.post, "/nemesis/like/$nemesisId", - ok: (_) => true, + return _req(http.post, "/user/$nemesisId/like", + ok: (_) => true, err: (_) => false ); } + Future postDislike(String nemesisId) { - return _req(http.post, "/nemesis/dislike/$nemesisId", - ok: (_) => true, + return _req(http.post, "/user/$nemesisId/dislike", + ok: (_) => true, err: (_) => false ); } -Future popExploreProfile({ required bool liked }) async { - final profile = _exploreProfiles.firstOrNull; - if (profile == null) return false; - final result = await (liked ? postLike(profile.id) : postDislike(profile.id)); - if (result) _exploreProfiles.removeAt(0); - return result; + +Future popExploreProfile({required bool liked}) async { + final profile = _exploreProfiles.firstOrNull; + if (profile == null) return false; + final result = await (liked ? postLike(profile.id) : postDislike(profile.id)); + if (result) _exploreProfiles.removeAt(0); + return result; } Uri _uri(String path, Map? params) { - return https ? Uri.https(host, path, params) : Uri.http(host, path, params); + return https ? + Uri.https(host, "$prefix$path", params) : + Uri.http(host, "$prefix$path", params); } + Future?> _authorized(Map? headers) async { - String? token = await auth.user?.getIdToken(); - if (token == null) { - log.warning("Attempted unauthorized HTTP request"); + String? token = await auth.user?.getIdToken(); + if (token == null) { + log.warning("Attempted unauthorized HTTP request"); return null; } - headers ??= {}; - headers["Authorization"] = "Bearer $token"; - return headers; + headers ??= {}; + headers["Authorization"] = "Bearer $token"; + return headers; } /* Nightmare methods that will make things easier and cleaner */ /*typedef Getter = Future Function(Uri uri, { Map? headers });*/ -typedef Requestor = Future Function(Uri uri, { - Map? headers, - String? body -}); +typedef Requestor = Future Function(Uri uri, + {Map? headers, String? body}); /* Get works differently than everything else does */ -Future _get( - String path, - { - Map? headers, - Map? params, - required T Function(http.Response) ok, - required T Function(http.Response?) err - } -) async { - final authorizedHeaders = await _authorized(headers); - if (authorizedHeaders == null) return err(null); - final res = await http.get(_uri(path, params), headers: authorizedHeaders); - return _handle(res, ok, err); -} -Future _req( - Requestor type, - String path, - { - Map? headers, - Map? params, - String? body, - required T Function(http.Response) ok, - required T Function(http.Response?) err - } -) async { - final authorizedHeaders = await _authorized(headers); - if (authorizedHeaders == null) return err(null); - final res = await type(_uri(path, params), headers: authorizedHeaders, body: body); - return _handle(res, ok, err); -} -T _handle(http.Response res, T Function(http.Response) ok, T Function(http.Response) err) { - if (res.statusCode == 200) return ok(res); - log.error("invalid HTTP response code: ${res.statusCode}"); - return err(res); +Future _get(String path, + {Map? headers, + Map? params, + required T Function(http.Response) ok, + required T Function(http.Response?) err}) async { + final authorizedHeaders = await _authorized(headers); + if (authorizedHeaders == null) return err(null); + final res = await http.get(_uri(path, params), headers: authorizedHeaders); + return _handle(path, res, ok, err); +} + +Future _req(Requestor type, String path, + {Map? headers, + Map? params, + String? body, + required T Function(http.Response) ok, + required T Function(http.Response?) err}) async { + final authorizedHeaders = await _authorized(headers); + if (authorizedHeaders == null) return err(null); + final res = + await type(_uri(path, params), headers: authorizedHeaders, body: body); + return _handle(path, res, ok, err); +} + +T _handle(String path, http.Response res, T Function(http.Response) ok, + T Function(http.Response) err) { + if (res.statusCode == 200) return ok(res); + log.error("invalid HTTP response code [$path]: ${res.statusCode}"); + return err(res); } diff --git a/lib/auth.dart b/lib/auth.dart index 97acc3e..e4b2490 100644 --- a/lib/auth.dart +++ b/lib/auth.dart @@ -11,23 +11,21 @@ bool get hasUser => user != null; Stream get stateChanges => _auth.authStateChanges(); Future signIn() async { - final GoogleSignInAccount? googleUser = await _googleSignIn.signIn(); - if (googleUser == null) return null; + final GoogleSignInAccount? googleUser = await _googleSignIn.signIn(); + if (googleUser == null) return null; - final GoogleSignInAuthentication googleAuth = - await googleUser.authentication; + final GoogleSignInAuthentication googleAuth = await googleUser.authentication; - final credential = GoogleAuthProvider.credential( - accessToken: googleAuth.accessToken, - idToken: googleAuth.idToken, - ); + final credential = GoogleAuthProvider.credential( + accessToken: googleAuth.accessToken, + idToken: googleAuth.idToken, + ); - UserCredential userCredential = await _auth.signInWithCredential(credential); - return userCredential.user; + UserCredential userCredential = await _auth.signInWithCredential(credential); + return userCredential.user; } Future signOut() async { - await _googleSignIn.signOut(); - await _auth.signOut(); + await _googleSignIn.signOut(); + await _auth.signOut(); } - diff --git a/lib/main.dart b/lib/main.dart index 3db92b1..954276b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:firebase_core/firebase_core.dart'; import 'firebase_options.dart'; -import 'profile.dart'; +//import 'profile.dart'; import 'pages/login.dart'; import 'pages/settings.dart'; @@ -13,77 +13,151 @@ import 'auth.dart' as auth; import 'api.dart' as api; import 'log.dart' as log; +import 'theme_manager.dart'; +import 'package:animated_bottom_navigation_bar/animated_bottom_navigation_bar.dart'; + void main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); - - runApp(MaterialApp( - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), - useMaterial3: true, - ), - home: Root() - )); - //log.i(await api.getMyProfile()); + + log.info(await api.getMyProfile()); + runApp( + AnimatedBuilder( + animation: ThemeManager.instance, + builder: (_, __) { + final mgr = ThemeManager.instance; + final vibrantSeed = + HSLColor.fromColor(mgr.seed).withSaturation(1.0).toColor(); + return MaterialApp( + theme: ThemeData.from( + colorScheme: ColorScheme.fromSeed(seedColor: vibrantSeed), + useMaterial3: true, + ), + darkTheme: ThemeData.from( + colorScheme: ColorScheme.fromSeed( + brightness: Brightness.dark, + seedColor: vibrantSeed, + ), + useMaterial3: true, + ), + themeMode: mgr.mode, + home: const Root(), + ); + }, + ), + ); + //log.i(await api.getMyProfile()); } class Root extends StatefulWidget { const Root({super.key}); - @override State createState() => RootState(); - + @override + State createState() => RootState(); } + class RootState extends State { - @override void initState() { - super.initState(); - auth.stateChanges.listen((dynamic _) { - // This is a mild anti-pattern - setState(() {}); - }); - } - - @override Widget build(BuildContext ctx) { + @override + void initState() { + super.initState(); + auth.stateChanges.listen((dynamic _) { + // This is a mild anti-pattern + setState(() {}); + }); + } + + @override + Widget build(BuildContext ctx) { if (auth.hasUser) { - return App(); - } else { - return LoginPage(); - } + return App(); + } else { + return LoginPage(); + //return App(); // use to circumvent login issues + } } } class App extends StatefulWidget { const App({super.key}); - @override State createState() => AppState(); + @override + State createState() => AppState(); } class AppState extends State { + int pageIdx = 2; + final List pages = [ + () => SettingsPage(), + () => MyProfilePage(), + () => ExplorePage(), + () => MatchesPage(), + ]; - - int pageIdx = 2; - final List pages = [ - () => SettingsPage(), - () => MyProfilePage(), - () => ExplorePage(), - () => MatchesPage(), - ]; - final List icons = [ - BottomNavigationBarItem(icon: Icon(Icons.settings), label: "Settings"), - BottomNavigationBarItem(icon: Icon(Icons.person), label: "Profile"), - BottomNavigationBarItem(icon: Icon(Icons.star), label: "Explore"), - BottomNavigationBarItem(icon: Icon(Icons.heart_broken), label: "Matches"), - ]; + final iconList = [ + Icons.settings, + Icons.person, + Icons.star, + Icons.heart_broken, + ]; @override Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final brightness = Theme.of(context).brightness; + final hsl = HSLColor.fromColor(cs.surface); + final barColor = (brightness == Brightness.light + ? hsl.withLightness((hsl.lightness - 0.20).clamp(0.0, 1.0)) + : hsl.withLightness((hsl.lightness + 0.20).clamp(0.0, 1.0))) + .toColor(); + return Scaffold( + extendBody: true, body: pages[pageIdx](), - bottomNavigationBar: BottomNavigationBar( - // for unknown reasons the navbar becomes (mostly) invisible when in "shifting" mode - type: BottomNavigationBarType.fixed, - currentIndex: pageIdx, - onTap: (int idx) { - setState(() => pageIdx = idx); - }, - items: icons + floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, + floatingActionButton: Container( + width: 72, + height: 72, + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.25), + blurRadius: 8, + spreadRadius: 1, + offset: const Offset(0, 0), + ), + ], + ), + child: FloatingActionButton( + onPressed: () {/* … */}, + backgroundColor: barColor, + elevation: 0, // shadow comes from the Container + shape: + const CircleBorder(), // ensures the FAB itself is perfectly round + // child: Icon(Icons.thumb_down, color: Colors.red), + child: Text( + '😡', // angry emoji + style: TextStyle( + fontSize: 32, // size the emoji + ), + ), + ), + ), + bottomNavigationBar: AnimatedBottomNavigationBar( + icons: iconList, + activeIndex: pageIdx, + gapLocation: GapLocation.center, + notchSmoothness: NotchSmoothness.smoothEdge, + leftCornerRadius: 32, + rightCornerRadius: 32, + activeColor: Colors.red, + inactiveColor: cs.onSurface.withValues(alpha: 0.6), + backgroundColor: barColor, + shadow: BoxShadow( + color: Colors.black.withValues(alpha: 0.25), + blurRadius: 8, + spreadRadius: 1, + offset: const Offset(0, -2), + ), + onTap: (idx) => setState(() => pageIdx = idx), ), ); } diff --git a/lib/pages/explore.dart b/lib/pages/explore.dart index f82fa3a..79249a5 100644 --- a/lib/pages/explore.dart +++ b/lib/pages/explore.dart @@ -1,64 +1,218 @@ - import 'package:flutter/material.dart'; -import '../profile.dart'; -import '../log.dart' as log; +import 'package:flutter_card_swiper/flutter_card_swiper.dart'; + +import 'package:hatingapp/profile.dart'; import 'package:hatingapp/api.dart' as api; +import 'package:hatingapp/log.dart' as log; +import 'dart:math'; + +/*class profile { + final String name; + final DateTime birthDate; + final String bio; + final List interests; + final String assetPath; + + profile({ + required this.name, + required this.birthDate, + required this.bio, + required this.interests, + required this.assetPath, + }); +} + +class Community { + final List members; -final class ExplorePage extends StatefulWidget { + Community({required this.members}); +} + +Community mockCommunity() { + return Community( + members: [ + profile( + name: 'Alice Johnson', + birthDate: DateTime(1990, 4, 12), + bio: 'Flutter fan, coffee addict', + interests: ['Flutter', 'Coffee', 'UI/UX'], + assetPath: 'https://picsum.photos/id/1005/1080/1080', + ), + profile( + name: 'Bob Smith', + birthDate: DateTime(1987, 8, 3), + bio: 'Passionate about open source.', + interests: ['Dart', 'Linux', 'Music'], + assetPath: 'https://picsum.photos/id/1001/1080/1080', + ), + profile( + name: 'Chris Evans', + birthDate: DateTime(1995, 11, 22), + bio: 'Backpacker, photographer', + interests: ['Travel', 'Hiking', 'Photography'], + assetPath: 'https://picsum.photos/id/1003/1080/1080', + ), + profile( + name: 'Diana Prince', + birthDate: DateTime(1992, 6, 10), + bio: 'Globe-trotter and foodie', + interests: ['Cuisine', 'Blogging', 'Culture'], + assetPath: 'https://picsum.photos/id/1011/1080/1080', + ), + ], + ); +}*/ + +class ExplorePage extends StatefulWidget { const ExplorePage({super.key}); - + @override - createState() => _ExplorePageState(); + State createState() => _ExplorePageState(); } class _ExplorePageState extends State { - - //Future future; - - @override build(BuildContext context) { + final CardSwiperController _controller = CardSwiperController(); + + @override + Widget build(BuildContext context) { - return FutureBuilder( - future: api.getNextExploreProfile(), - builder: (context, snapshot) { - if (snapshot.connectionState != ConnectionState.done) { - return Center(child: CircularProgressIndicator()); - } else { - final profile = snapshot.data; - final profileView = profile == null - ? ProfileView(Profile.dummy("")) //Center(child: Text("No more profiles!")) - : ProfileView(profile); - - return Stack(children: [ - profileView, - Positioned( - bottom: 10.0, - left: 10.0, - child: IconButton.filled( - icon: Icon(Icons.close), - onPressed: () async { - await api.popExploreProfile(liked: false); - log.debug("Disiked!"); - setState(() {}); - } - ) - ), - Positioned( - bottom: 10.0, - right: 10.0, - child: IconButton.filled( - icon: Icon(Icons.check), - onPressed: () async { - await api.popExploreProfile(liked: false); - log.debug("Liked!"); - setState(() {}); + + + return Scaffold( + body: FutureBuilder( + future: api.getExploreProfiles(), + builder: (context, snapshot) { + final members = snapshot.data; + if (members == null) { + return Center(child: CircularProgressIndicator()); + } else { + log.info(members); + if (members.isEmpty) { + members.add(Profile.dummy()); + } + return CardSwiper( + controller: _controller, + cardsCount: members.length + 2, + //numberOfCardsDisplayed: 2, + backCardOffset: const Offset(20, 20), + scale: 0.9, + padding: EdgeInsets.zero, + //isLoop: true, + isLoop: false, + + cardBuilder: ( + BuildContext context, + int index, + int horizontalOffsetPercentage, + int verticalOffsetPercentage, + ) { + if (index < 0 || index > members.length) { + return null; + } else if (index == members.length) { + return Center( + child: Text("Nobody left!") + ); } - ), - ) - ]); + + const defaultPhotos = [ + 'https://picsum.photos/400/600?image=1011', + 'https://picsum.photos/400/600?image=1022', + 'https://picsum.photos/400/600?image=1033', + ]; + + final profile = members[index]; + final tags = profile.tags ?? []; + final photos = profile.photos ?? [defaultPhotos[Random().nextInt(3)]]; + + return Stack( + children: [ + // Full-screen image from network URL + Positioned.fill( + child: Image.network( + photos.first, + fit: BoxFit.cover, + ), + ), + // Semi-transparent overlay with profile info (bottom-left) + Positioned( + left: 16, + right: 16, + bottom: 130, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + profile.displayName, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + profile.bio, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 4, + children: tags.map((interest) { + return Chip( + label: Text( + interest, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + backgroundColor: + Colors.black.withValues(alpha: 0.4), + ); + }).toList(), + ) + ], + ), + ), + ), + ], + ); + }, + onSwipe: (previousIndex, currentIndex, direction) { + debugPrint( + 'Swiped card $previousIndex ${direction.name}; now top is $currentIndex'); + api.popExploreProfile(liked: true); + return true; + }, + onUndo: (previousIndex, currentIndex, direction) { + debugPrint('Undoing card $currentIndex from ${direction.name}'); + return true; + }, + ); + } + + + } - } + + ) ); - - + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); } } diff --git a/lib/pages/login.dart b/lib/pages/login.dart index 017757e..794c4f5 100644 --- a/lib/pages/login.dart +++ b/lib/pages/login.dart @@ -2,9 +2,8 @@ import 'package:flutter/material.dart'; import '../auth.dart' as auth; class LoginPage extends StatelessWidget { - - const LoginPage({ super.key }); - + const LoginPage({super.key}); + @override Widget build(BuildContext context) { return Scaffold( diff --git a/lib/pages/matches.dart b/lib/pages/matches.dart index 5dde06e..bdb0f72 100644 --- a/lib/pages/matches.dart +++ b/lib/pages/matches.dart @@ -1,48 +1,243 @@ - import 'package:flutter/material.dart'; import '../profile.dart'; +import 'package:hatingapp/api.dart' as api; +import './myprofile.dart'; -/*class Match { - Profile profile; - //Profile? profile; // We may not have their profile from the server yet -}*/ - +class ChatMessage { + final String text; + final bool isMine; + ChatMessage({required this.text, required this.isMine}); +} class MatchesPage extends StatelessWidget { + + const MatchesPage({super.key}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final txt = Theme.of(context).textTheme; + + return Scaffold( + appBar: AppBar( + title: const Text("Matches"), + backgroundColor: cs.surface, + foregroundColor: cs.onSurface, + elevation: 1, + ), + backgroundColor: cs.surface, + + body: FutureBuilder( + future: api.getMatches(), + builder: (context, snapshot) { + final profiles = snapshot.data; + if (profiles == null) { + return Center(child: CircularProgressIndicator()); + } + if (profiles.isEmpty) { + return Center(child: Text("No matches yet!")); + } + return ListView.separated( + itemCount: profiles.length, + separatorBuilder: (_, __) => Divider( + color: cs.onSurface.withValues(alpha: 0.2), + indent: 16, + endIndent: 16, + height: 1, + ), + itemBuilder: (context, i) { + final profile = profiles[i]; + final initial = + profile.displayName.isNotEmpty ? profile.displayName[0] : '?'; + + return InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ChatPage(profile: profile), + ), + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ProfilePage(profile), + ) + ); + }, // Image tapped + child: CircleAvatar( + radius: 28, + backgroundColor: cs.primaryContainer, + child: Text( + initial, + style: txt.titleMedium + ?.copyWith(color: cs.onPrimaryContainer), + ), + ), + ), + + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + profile.displayName, + style: txt.titleMedium?.copyWith(color: cs.onSurface), + ), + const SizedBox(height: 4), + Text( + "Say hi!", + style: txt.bodySmall?.copyWith( + color: cs.onSurface.withValues(alpha: 0.6), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Icon(Icons.chevron_right, color: cs.onSurface), + ], + ), + ), + ); + }, + ); + } + ), + ); + } +} + +class ProfilePage extends StatelessWidget { - final List profiles = [ - Profile.dummy("Match 1"), - Profile.dummy("Match 2"), - Profile.dummy("Match 3"), - //Profile("Match 1", DateTime.now(), "Example Bio", ["I1", "I2"]), - //Profile("Match 2", DateTime.now(), "Example Bio", ["I1", "I2"]), - //Profile("Match 3", DateTime.now(), "Example Bio", ["I1", "I2"]), - ]; - MatchesPage({ super.key }); + final Profile profile; + const ProfilePage(this.profile, {super.key}); - @override - Widget build(BuildContext context) { + @override build(BuildContext context) { - Widget profileEntryBuilder(Profile profile) { - return ListTile( - title: Text(profile.displayName), - subtitle: Text("[latest message...]"), - leading: SizedBox.fromSize( - size: Size(40.0, 40.0), - child: Placeholder() - ), - trailing: Icon(Icons.arrow_right) - ); - } + final cs = Theme.of(context).colorScheme; + final txt = Theme.of(context).textTheme; - return ListView.separated( - itemCount: profiles.length, - itemBuilder: (context, i) => profileEntryBuilder(profiles[i]), - separatorBuilder: (context, i) => Divider(color: Colors.blueGrey[700], thickness: 2, indent: 10, endIndent: 10) + return Scaffold( + appBar: AppBar( + title: Text(profile.displayName), + backgroundColor: cs.surface, + foregroundColor: cs.onSurface, + ), + body: ProfileView(profile), ); } } +class ChatPage extends StatefulWidget { + final Profile profile; + const ChatPage({required this.profile, super.key}); + @override + ChatPageState createState() => ChatPageState(); +} +class ChatPageState extends State { + final TextEditingController _controller = TextEditingController(); + final List _messages = []; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final txt = Theme.of(context).textTheme; + + return Scaffold( + appBar: AppBar( + title: Text(widget.profile.displayName), + backgroundColor: cs.surface, + foregroundColor: cs.onSurface, + + ), + backgroundColor: cs.surface, + body: Column( + children: [ + Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + itemCount: _messages.length, + itemBuilder: (_, i) { + final msg = _messages[i]; + return Align( + alignment: + msg.isMine ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: msg.isMine + ? cs.primaryContainer + : cs.secondaryContainer, + borderRadius: BorderRadius.circular(16), + ), + child: Text( + msg.text, + style: txt.bodyMedium?.copyWith( + color: msg.isMine + ? cs.onPrimaryContainer + : cs.onSecondaryContainer, + ), + ), + ), + ); + }, + ), + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + decoration: InputDecoration( + fillColor: cs.surface, + filled: true, + hintText: 'Type a message', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 0), + ), + ), + ), + const SizedBox(width: 8), + IconButton( + icon: Icon(Icons.send, color: cs.primary), + onPressed: () { + final text = _controller.text.trim(); + if (text.isNotEmpty) { + setState(() { + _messages.add(ChatMessage(text: text, isMine: true)); + }); + _controller.clear(); + } + }, + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/myprofile.dart b/lib/pages/myprofile.dart index af8a93a..3146251 100644 --- a/lib/pages/myprofile.dart +++ b/lib/pages/myprofile.dart @@ -1,69 +1,256 @@ - import 'package:flutter/material.dart'; -import '../profile.dart'; -import '../api.dart' as api; +import 'package:hatingapp/profile.dart'; +import 'package:hatingapp/api.dart' as api; + +/*class DummyProfile { + final List photoUrls; + final String displayName; + final DateTime birthDate; + final String bio; + final List interests; + //final String longDescription; + + DummyProfile({ + required this.photoUrls, + required this.displayName, + required this.birthDate, + required this.bio, + required this.interests, + //required this.longDescription, + }); + + factory DummyProfile.example() => DummyProfile( + photoUrls: [ + 'https://picsum.photos/400/600?image=1011', + 'https://picsum.photos/400/600?image=1022', + 'https://picsum.photos/400/600?image=1033', + ], + displayName: 'Alex', + birthDate: DateTime(1995, 6, 15), + bio: 'Lover of hikes, coffee, and spontaneous road trips.', + interests: ['Hiking', 'Coffee', 'Music', 'Cooking'], + /*longDescription: + 'Software engineer by day, amateur photographer by weekend. ' + 'Always looking for the next adventure or a lazy Sunday in.',*/ + ); +}*/ class MyProfilePage extends StatefulWidget { const MyProfilePage({super.key}); - @override createState() => _MyProfilePageState(); + + @override + MyProfilePageState createState() => MyProfilePageState(); } -class _MyProfilePageState extends State { +class MyProfilePageState extends State { @override - build(BuildContext context) { + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return FutureBuilder( future: api.getMyProfile(), builder: (context, snapshot) { - final Profile? profile = snapshot.data; + final profile = snapshot.data; if (profile == null) { return Center(child: CircularProgressIndicator()); } else { - return Stack(children: [ - ProfileView(profile), - Positioned( - top: 60, - right: 10, - child: IconButton( - icon: Icon(Icons.menu), - onPressed: () async { - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => EditProfile(profile) - ) - ); - setState(() {}); - }, - ) - ) - ]); + return Scaffold( + backgroundColor: cs.surface, + body: Stack( + children: [ + ProfileView(profile), + Positioned( + top: 60, + right: 10, + child: IconButton( + icon: Icon(Icons.menu, color: cs.onSurface), + onPressed: () async { + await Navigator.push( + context, + MaterialPageRoute( + ////////////////Commented out until api integration////////// + /// builder: (context) => EditProfile(profile), + //////////////////////////////////////////////////////////// + builder: (context) => EditProfile(profile), // use for now + ), + ); + setState(() {}); + }, + ), + ), + ], + ), + ); } } ); - } + } } +class ProfileView extends StatefulWidget { + final Profile profile; + const ProfileView(this.profile, {super.key}); + @override + ProfileViewState createState() => ProfileViewState(); +} + +class ProfileViewState extends State { + late final PageController _pageController; + int _currentPage = 0; + @override + void initState() { + super.initState(); + _pageController = PageController(); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } -//////////////////////////////////////////////////// -/// Use: use navigator.push and pass a profile class. -/// then put : -/// -/// .then((_) { -/// setState(() { -/// widget.myProfile; -/// }); -/// } -/// immediately after the Navigator.push argument -/// the profile will be edited in the next page -/// once the data is saved and pop'd off the widget tree -/// the profile data will be updated on the current page -/////////////////////////////////////////////////////// + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final txt = Theme.of(context).textTheme; + final profile = widget.profile; + final photos = profile.photos ?? []; + + return Scaffold( + backgroundColor: cs.surface, + body: NestedScrollView( + headerSliverBuilder: (_, __) => [ + SliverAppBar( + backgroundColor: cs.surface, + expandedHeight: 480, + pinned: true, + automaticallyImplyLeading: false, + flexibleSpace: FlexibleSpaceBar( + background: Stack( + fit: StackFit.expand, + children: [ + PageView.builder( + controller: _pageController, + itemCount: photos.length, + onPageChanged: (i) => setState(() => _currentPage = i), + itemBuilder: (_, i) => Image.network( + photos[i], + fit: BoxFit.cover, + loadingBuilder: (ctx, child, prog) => prog == null + ? child + : const Center(child: CircularProgressIndicator()), + ), + ), + Positioned( + bottom: 16, + left: 0, + right: 0, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(photos.length, (i) { + final selected = i == _currentPage; + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.symmetric(horizontal: 4), + width: selected ? 12 : 8, + height: selected ? 12 : 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: cs.onSurface.withValues( + alpha: selected ? 0.9 : 0.4, + ), + ), + ); + }), + ), + ), + ], + ), + ), + ), + ], + body: ListView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${widget.profile.displayName}, ${profile.age}', + style: txt.headlineSmall?.copyWith( + color: cs.onSurface, + fontWeight: FontWeight.bold, + ), + ), + Icon(Icons.mail, color: cs.onSurface), + ], + ), + const SizedBox(height: 8), + /*Text( + widget.profile.bio, + style: txt.bodyMedium?.copyWith( + color: cs.onSurface.withValues(alpha: 0.8), + ), + ),*/ + const SizedBox(height: 16), + + FutureBuilder( + future: api.getProfileTags(profile), + builder: (context, snapshot) { + final tags = snapshot.data; + if (tags == null) { + return SizedBox(); + } else { + return Wrap( + spacing: 8, + runSpacing: 8, + children: tags.map((tag) { + return Chip( + label: Text( + tag, + style: txt.bodySmall?.copyWith( + color: cs.onPrimaryContainer, + ), + ), + backgroundColor: cs.primaryContainer, + ); + }).toList(), + ); + } + } + ), + + + const SizedBox(height: 24), + Text( + 'About me', + style: txt.titleMedium?.copyWith( + color: cs.onSurface, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + widget.profile.bio, + style: txt.bodyMedium?.copyWith( + color: cs.onSurface.withValues(alpha: 0.8), + ), + ), + const SizedBox(height: 80), + ], + ), + ), + ); + } +} class EditProfile extends StatefulWidget { - const EditProfile(this.myProfile, {super.key}); - final Profile myProfile; + ////////////////Commented out until api integration///////////////// + const EditProfile(this.profile, {super.key}); + final Profile profile; + /////////////////////////////////////////////////////////////// @override State createState() => _EditProfileState(); } @@ -72,6 +259,7 @@ class _EditProfileState extends State { DateTime? selectedDate = DateTime.now(); String? enteredName = "name"; String? enteredBio = "bio"; + final TextEditingController nameController = TextEditingController(); final TextEditingController bioController = TextEditingController(); @@ -95,7 +283,7 @@ class _EditProfileState extends State { } return null; } - + String? bioValidator(String? value) { if (value == null || value.isEmpty) { return 'This field is required'; @@ -119,10 +307,18 @@ class _EditProfileState extends State { if (!isValid) { return; } + + widget.profile.displayName = nameController.text; + widget.profile.bio = bioController.text; + await api.patchMyProfile(widget.profile); - setState(() => isLoading = true); - final String? errorText = - await validateUsernameFromServer(nameController.text); + /*setState(() => isLoading = true); + final String? errorText = await validateUsernameFromServer( + nameController.text, + ); + + + if (context.mounted) { setState(() => isLoading = false); @@ -130,18 +326,29 @@ class _EditProfileState extends State { setState(() { forceErrorText = errorText; }); - } + } else { + + } - setState(() { + setState(() async { + // widget.myProfile.update(nameController.text, // selectedDate ?? DateTime.now(), bioController.text); - widget.myProfile.displayName = nameController.text; + + ////////////////Commented out until api integration////////// + /// widget.myProfile.displayName = nameController.text; + ///////////////////////////////////////////////////////////// + //widget.myProfile.birthDate = selectedDate ?? DateTime.now(); - widget.myProfile.bio = bioController.text; - api.patchMyProfile(widget.myProfile); + + ////////////////Commented out until api integration////////// + /// widget.myProfile.bio = bioController.text; + /// api.patchMyProfile(widget.myProfile); + //////////////////////////////////////////////////////////// + //print("Saved"); }); - } + }*/ } Future validateUsernameFromServer(String username) async { @@ -179,53 +386,53 @@ class _EditProfileState extends State { ), body: Center( child: Padding( - padding: EdgeInsets.all(16.0), - child: Form( - key: nameFormKey, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - TextFormField( - forceErrorText: forceErrorText, - controller: nameController, - validator: validator, - onChanged: onChanged, - decoration: const InputDecoration( - border: UnderlineInputBorder(), - labelText: 'Enter your name', - ), - ), - TextFormField( - minLines: 1, - maxLines: 10, - maxLength: 1000, - decoration: const InputDecoration( - border: UnderlineInputBorder(), - labelText: 'What really grinds your gears', - ), - forceErrorText: forceErrorText, - controller: bioController, - validator: bioValidator, - onChanged: onChanged, - ), - Text( - selectedDate != null - ? '${selectedDate!.day}/${selectedDate!.month}/${selectedDate!.year}' - : 'No date selected', - ), - TextButton( - onPressed: _selectDate, - child: const Text("Birthday")), - if (isLoading) - const CircularProgressIndicator() - else - TextButton( - onPressed: onSave, - child: Text('Save'), - ) - ]))), + padding: EdgeInsets.all(16.0), + child: Form( + key: nameFormKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextFormField( + forceErrorText: forceErrorText, + controller: nameController, + validator: validator, + onChanged: onChanged, + decoration: const InputDecoration( + border: UnderlineInputBorder(), + labelText: 'Enter your name', + ), + ), + TextFormField( + minLines: 1, + maxLines: 10, + maxLength: 1000, + decoration: const InputDecoration( + border: UnderlineInputBorder(), + labelText: 'What really grinds your gears', + ), + forceErrorText: forceErrorText, + controller: bioController, + validator: bioValidator, + onChanged: onChanged, + ), + Text( + selectedDate != null + ? '${selectedDate!.day}/${selectedDate!.month}/${selectedDate!.year}' + : 'No date selected', + ), + TextButton( + onPressed: _selectDate, + child: const Text("Birthday"), + ), + if (isLoading) + const CircularProgressIndicator() + else + TextButton(onPressed: onSave, child: Text('Save')), + ], + ), + ), + ), ), ); } } - diff --git a/lib/pages/settings.dart b/lib/pages/settings.dart index 3b9248e..d3ba542 100644 --- a/lib/pages/settings.dart +++ b/lib/pages/settings.dart @@ -1,18 +1,83 @@ import 'package:flutter/material.dart'; +import '../theme_manager.dart'; import '../auth.dart'; -import 'login.dart'; +//import 'login.dart'; + +class SettingsPage extends StatefulWidget { + const SettingsPage({super.key}); + @override + State createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + late ThemeMode _currentMode; + late Color _currentSeed; + + static const Map _seedOptions = { + 'Red': Colors.red, + 'Purple': Colors.deepPurple, + 'Blue': Colors.blue, + 'Green': Colors.green, + 'Yellow': Colors.yellow, + }; + + @override + void initState() { + super.initState(); + final mgr = ThemeManager.instance; + _currentMode = mgr.mode; + _currentSeed = mgr.seed; + } -class SettingsPage extends StatelessWidget { @override Widget build(BuildContext context) { - return Center( - child: ElevatedButton.icon( - icon: Icon(Icons.logout), - label: Text("Logout"), - onPressed: () async { - await signOut(); - }, + return Scaffold( + appBar: AppBar(title: const Text('Settings')), + body: ListView( + children: [ + const ListTile(title: Text('Theme Mode')), + // iterate over built‑in ThemeMode values + for (var mode in ThemeMode.values) + RadioListTile( + title: Text(mode.name.capitalize()), + value: mode, + groupValue: _currentMode, + onChanged: (chosen) { + if (chosen == null) return; + setState(() => _currentMode = chosen); + ThemeManager.instance.updateMode(chosen); + }, + ), + + const Divider(), + const ListTile(title: Text('Seed Color')), + // iterate over our seed options + for (var entry in _seedOptions.entries) + RadioListTile( + title: Text(entry.key), + value: entry.value, + groupValue: _currentSeed, + onChanged: (chosen) { + if (chosen == null) return; + setState(() => _currentSeed = chosen); + ThemeManager.instance.updateSeed(chosen); + }, + ), + + const Divider(), + ElevatedButton.icon( + icon: const Icon(Icons.logout), + label: const Text("Logout"), + onPressed: () => signOut(), + ), + SizedBox(height: 40), + ], ), ); } } + +// helper to capitalize radio titles +extension on String { + String capitalize() => isEmpty ? this : this[0].toUpperCase() + substring(1); +} diff --git a/lib/profile.dart b/lib/profile.dart index 937de84..1d2f3ae 100644 --- a/lib/profile.dart +++ b/lib/profile.dart @@ -1,23 +1,38 @@ - import 'package:flutter/material.dart'; import 'log.dart'; class Profile { - //String name; //DateTime birthDate; //String bio; //List interests; - + final String id; String username; // should maybe be final String displayName; String avatarUrl; String bio; + DateTime? birthDate; + List? photos; List? tags; //List embedding; //DateTime createdAt; //DateTime updatedAt; + + int get age { + final bd = birthDate; + + if (bd == null) { + return 21; + } + + final now = DateTime.now(); + var a = now.year - bd.year; + if (now.month < bd.month || (now.month == bd.month && now.day < bd.day)) { + a--; + } + return a; + } Profile({ required this.id, @@ -26,29 +41,36 @@ class Profile { required this.avatarUrl, required this.bio, this.tags, + this.photos = const [ + 'https://picsum.photos/400/600?image=1011', + 'https://picsum.photos/400/600?image=1022', + 'https://picsum.photos/400/600?image=1033', + ], + this.birthDate, }); - factory Profile.dummy(String displayName, { - String bio = "", - List tags = const ["Test Tag #1", "Test Tag #2"] - }) { - return Profile( - id: "dummy", - username: "dummy", - displayName: displayName, - avatarUrl: "", - bio: bio, - tags: tags - ); - } - - /*void update(String name, DateTime birthDate, String bio) { - name = name_; - birthDate = birthDate_; - bio = bio_; - }*/ + + + factory Profile.dummy() => Profile( + id: "abcdef", + username: "whatever", + //displayName: "Dummy!!", + /*photos: [ + 'https://picsum.photos/400/600?image=1011', + 'https://picsum.photos/400/600?image=1022', + 'https://picsum.photos/400/600?image=1033', + ],*/ + avatarUrl: 'https://picsum.photos/400/600?image=1011', + displayName: 'Alex', + birthDate: DateTime(1995, 6, 15), + bio: 'Lover of hikes, coffee, and spontaneous road trips.', + tags: ['Hiking', 'Coffee', 'Music', 'Cooking'], + /*longDescription: + 'Software engineer by day, amateur photographer by weekend. ' + 'Always looking for the next adventure or a lazy Sunday in.',*/ + ); } -class ProfileView extends StatelessWidget { +/*class ProfileView extends StatelessWidget { final Profile profile; final bool editable; // doesn't work yet // not sure whether profile editing should happen on another page or not @@ -62,20 +84,18 @@ class ProfileView extends StatelessWidget { Widget? interestsView(List? interests) { if (interests == null) return null; return Text.rich(TextSpan( - children: interests.map((interest) { - return WidgetSpan( + children: interests.map((interest) { + return WidgetSpan( child: Card( - margin: EdgeInsets.fromLTRB(3.0, 3.0, 3.0, 3.0), - color: Colors.grey[400], //Theme.of(context).cardColor, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))), - child: Padding( - padding: EdgeInsets.fromLTRB(5.0, 3.0, 5.0, 3.0), - child: Text(interest) - ), - ) - ); - } - ).toList())); + margin: EdgeInsets.fromLTRB(3.0, 3.0, 3.0, 3.0), + color: Colors.grey[400], //Theme.of(context).cardColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4.0))), + child: Padding( + padding: EdgeInsets.fromLTRB(5.0, 3.0, 5.0, 3.0), + child: Text(interest)), + )); + }).toList())); } final children = [ @@ -149,5 +169,4 @@ class ProfileView extends StatelessWidget { ), );*/ } -} - +}*/ diff --git a/lib/theme_manager.dart b/lib/theme_manager.dart new file mode 100644 index 0000000..3bc1fad --- /dev/null +++ b/lib/theme_manager.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +class ThemeManager extends ChangeNotifier { + static final ThemeManager instance = ThemeManager._(); + + ThemeMode _mode = ThemeMode.system; + Color _seed = Colors.red; + + ThemeManager._(); + + ThemeMode get mode => _mode; + Color get seed => _seed; + + void updateMode(ThemeMode newMode) { + if (newMode == _mode) return; + _mode = newMode; + notifyListeners(); + } + + void updateSeed(Color newSeed) { + if (newSeed == _seed) return; + _seed = newSeed; + notifyListeners(); + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 7b9be20..9ae8802 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,8 +7,10 @@ import Foundation import firebase_auth import firebase_core +import google_sign_in_ios func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) } diff --git a/pubspec.yaml b/pubspec.yaml index b71d0b1..e487242 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,8 @@ dependencies: firebase_core: ^3.12.1 google_sign_in: ^6.3.0 http: ^1.3.0 + flutter_card_swiper: ^7.0.2 + animated_bottom_navigation_bar: ^1.4.0 dev_dependencies: flutter_test: