diff --git a/assets/icon/is-transparent-crop.png b/assets/icon/is-transparent-crop.png new file mode 100644 index 0000000..44e8c07 Binary files /dev/null and b/assets/icon/is-transparent-crop.png differ diff --git a/assets/icon/is-transparent.png b/assets/icon/is-transparent.png new file mode 100644 index 0000000..45fdd79 Binary files /dev/null and b/assets/icon/is-transparent.png differ diff --git a/lib/animations/page/slide.dart b/lib/animations/page/slide.dart new file mode 100644 index 0000000..38f48f3 --- /dev/null +++ b/lib/animations/page/slide.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +class SlidePageRoute extends PageRouteBuilder { + final Widget child; + final AxisDirection direction; + final Duration duration; + + SlidePageRoute({ + super.settings, + required this.child, + this.duration = const Duration(milliseconds: 400), + this.direction = AxisDirection.left, + }) : super( + pageBuilder: + ( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) => child, + transitionDuration: duration, // Adjust duration as needed + transitionsBuilder: + ( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + Offset beginOffset; + switch (direction) { + case AxisDirection.up: + beginOffset = Offset(0, 1); + break; + case AxisDirection.down: + beginOffset = Offset(0, -1); + break; + case AxisDirection.left: + beginOffset = Offset(1, 0); + break; + case AxisDirection.right: + beginOffset = Offset(-1, 0); + break; + } + final endOffset = Offset(-beginOffset.dx, -beginOffset.dy); + + final tween = Tween(begin: beginOffset, end: Offset.zero); + final previousTween = Tween(begin: Offset.zero, end: endOffset); + final curvedAnimation = CurvedAnimation( + parent: animation, + curve: Curves.easeInOut, // Choose your curve + ); + + return SlideTransition( + position: tween.animate(curvedAnimation), + child: child, + ); + }, + ); +} diff --git a/lib/appstate.dart b/lib/appstate.dart index 84c1767..c98cca7 100644 --- a/lib/appstate.dart +++ b/lib/appstate.dart @@ -5,10 +5,11 @@ import 'dart:convert'; const appStateKey = "app-state"; class AppState { - final models.Session? session; - final models.User? user; - const AppState({this.session, this.user}); + models.Session? session; + models.User? user; + AppState({this.session, this.user}); static Future load() async { + await Future.delayed(Duration(seconds: 5)); final prefs = await SharedPreferences.getInstance(); final appStateJson = prefs.getString(appStateKey); if (appStateJson != null) { diff --git a/lib/backend.dart b/lib/backend.dart index 708f06c..e4760a9 100644 --- a/lib/backend.dart +++ b/lib/backend.dart @@ -12,20 +12,33 @@ Future> query( Map data, models.Session? session, ) async { - final response = await http.post( - Uri.parse("${getBackendBase()}/api/$url"), - body: data, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', - if (session != null) ...{ - 'session-token': session.token, - 'session-id': session.id.toString(), + late http.Response response; + try { + response = await http.post( + Uri.parse("${getBackendBase()}/api/$url"), + body: data, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + if (session != null) ...{ + 'session-token': session.token, + 'session-id': session.id.toString(), + }, }, - }, - ); + ); + } catch (e) { + throw Exception( + 'Could not connect to backend, check your internet connection.', + ); + } if (response.statusCode == 200) { return jsonDecode(response.body); } else { throw Exception('Failed to load data from backend(Invalid status code)'); } } + +class Status { + static const notFound = -5; + static const error = -1; + static const ok = 0; +} diff --git a/lib/models.dart b/lib/models.dart index 7253cdd..b9502e4 100644 --- a/lib/models.dart +++ b/lib/models.dart @@ -1,2 +1,3 @@ export 'models/session.dart'; export 'models/user.dart'; +export 'models/school.dart'; diff --git a/lib/models/chatmessage.dart b/lib/models/chatmessage.dart new file mode 100644 index 0000000..70b1c96 --- /dev/null +++ b/lib/models/chatmessage.dart @@ -0,0 +1,72 @@ +import 'dart:ffi'; + +import 'model.dart'; +import 'thread.dart'; +import 'user.dart'; +import 'session.dart'; +import 'package:ins/backend.dart'; + +class Chatmessage implements Model { + final int id; + final int threadId; + final int userId; + final String content; + final DateTime createdAt; + final DateTime? editedAt; + final Map reactions; + final int? repliesTo; + final int? repliesThread; + const Chatmessage({ + required this.id, + required this.threadId, + required this.userId, + required this.content, + required this.createdAt, + required this.editedAt, + required this.reactions, + required this.repliesTo, + required this.repliesThread, + }); + factory Chatmessage.fromJson(Map data) { + return Chatmessage( + id: data['id'] as int, + threadId: data['thread_id'] as int, + userId: data['user_id'] as int, + content: data['content'] as String, + createdAt: DateTime.parse(data['created_at'] as String), + editedAt: data['edited_at'] == null + ? null + : DateTime.parse(data['edited_at'] as String), + reactions: data['reactions'] as Map, + repliesTo: data['replies_to'] as int?, + repliesThread: data['replies_thread'] as int?, + ); + } + @override + Map toJson() { + return { + "id": id, + "thread_id": threadId, + "user_id": userId, + "content": content, + "created_at": createdAt.toIso8601String(), + "edited_at": editedAt?.toIso8601String(), + "reactions": reactions, + "replies_to": repliesTo, + "replies_thread": repliesThread, + }; + } + + Future getThread(Session? session) { + return Thread.getByID(session, threadId); + } + + Future getUser(Session? session) { + return User.getByID(session, userId); + } + + Future getRepliesThread(Session? session) async { + if (repliesThread == null) return null; + return await Thread.getByID(session, repliesThread!); + } +} diff --git a/lib/models/chatroom.dart b/lib/models/chatroom.dart new file mode 100644 index 0000000..4e92322 --- /dev/null +++ b/lib/models/chatroom.dart @@ -0,0 +1,62 @@ +import 'chatserver.dart'; + +import 'model.dart'; +import 'session.dart'; +import 'package:ins/backend.dart' as backend; +import 'thread.dart'; + +class Chatroom implements Model { + final int id; + final String title; + final String? description; + final int? serverId; + final int? threadId; + final DateTime createdAt; + const Chatroom({ + required this.id, + required this.title, + required this.description, + required this.serverId, + required this.threadId, + required this.createdAt, + }); + factory Chatroom.fromJson(Map data) { + return Chatroom( + id: data['id'] as int, + title: data['title'] as String, + description: data['description'] as String?, + serverId: data['server_id'] as int?, + threadId: data['thread_id'] as int?, + createdAt: DateTime.parse(data['created_at'] as String), + ); + } + @override + Map toJson() { + return { + "id": id, + "title": title, + "description": description, + "server_id": serverId, + "thread_id": threadId, + "created_at": createdAt.toIso8601String(), + }; + } + + static Future getById(Session? session, int id) async { + final data = await backend.query("v1/chatroom/get", {"id": id}, session); + if (data['status'] as int < 0) { + throw "Error getting chatroom $id: ${data['message'] as String}"; + } + return Chatroom.fromJson(data['room'] as Map); + } + + Future getServer(Session? session) async { + if (serverId == null) return null; + return Chatserver.getById(session, serverId!); + } + + Future getThread(Session? session) async { + if (threadId == null) return null; + return Thread.getByID(session, threadId!); + } +} diff --git a/lib/models/chatserver.dart b/lib/models/chatserver.dart new file mode 100644 index 0000000..e097307 --- /dev/null +++ b/lib/models/chatserver.dart @@ -0,0 +1,27 @@ +import 'model.dart'; +import 'session.dart'; +import 'package:ins/backend.dart' as backend; + +class Chatserver implements Model { + final int id; + final String fullname; + const Chatserver({required this.id, required this.fullname}); + @override + Map toJson() { + return {"id": id, "fullname": fullname}; + } + + factory Chatserver.fromJson(Map data) { + return Chatserver( + id: data['id'] as int, + fullname: data['schoolname'] as String, + ); + } + static Future getById(Session? session, int id) async { + final data = await backend.query("v1/chatserver/get", {"id": id}, session); + if (data['status'] as int < 0) { + throw "Error getting charserver $id: ${data['message'] as String}"; + } + return Chatserver.fromJson(data['server'] as Map); + } +} diff --git a/lib/models/class.dart b/lib/models/class.dart new file mode 100644 index 0000000..b4f11fc --- /dev/null +++ b/lib/models/class.dart @@ -0,0 +1,68 @@ +import 'package:ins/models.dart'; + +import 'school_user.dart'; +import 'package:ins/backend.dart' as backend; + +import 'model.dart'; + +class Class implements Model { + final int id; + final int schoolId; + final String fullname; + final int? chatserverId; + final String? description; + final DateTime createdAt; + const Class({ + required this.id, + required this.schoolId, + required this.fullname, + required this.chatserverId, + required this.description, + required this.createdAt, + }); + @override + Map toJson() { + return { + "id": id, + "school_id": schoolId, + "fullname": fullname, + "chatserver_id": chatserverId, + "description": description, + "created_at": createdAt, + }; + } + + factory Class.fromJson(Map data) { + return Class( + id: data['id'] as int, + schoolId: data['school_id'] as int, + fullname: data['schoolname'] as String, + chatserverId: data['schoolserver_id'] as int?, + description: data['description'] as String?, + createdAt: DateTime.parse(data['created_at'] as String), + ); + } + static Future getById(Session? session, int id) async { + final data = await backend.query("v1/class/get", {"id": id}, session); + if (data['status'] as int < 0) { + throw "Error fetching class with id $id: ${data['message'] as String}"; + } + return Class.fromJson(data['class']); + } + + Future> getMembers(Session? session) async { + final data = await backend.query("v1/class/getmembers", { + "class_id": id, + }, session); + if (data['status'] as int < 0) { + throw "Error getting members of '$fullname', ${data['message'] as String}"; + } + return (data["users"] as List>) + .map(SchoolUser.fromJson) + .toList(); + } + + Future getSchool(Session? session) { + return School.getByID(session, schoolId); + } +} diff --git a/lib/models/school.dart b/lib/models/school.dart new file mode 100644 index 0000000..509d6db --- /dev/null +++ b/lib/models/school.dart @@ -0,0 +1,52 @@ +import 'package:ins/models/school_user.dart'; + +import 'model.dart'; +import 'package:ins/backend.dart' as backend; +import 'session.dart'; + +class School implements Model { + final int id; + final String schoolname; + final String fullname; + final String? description; + final int? chatserverId; + const School({ + required this.id, + required this.schoolname, + required this.fullname, + required this.description, + required this.chatserverId, + }); + @override + Map toJson() { + return { + "id": id, + "schoolname": schoolname, + "fullname": fullname, + "description": description, + "chatserver_id": chatserverId, + }; + } + + factory School.fromJson(Map obj) { + return School( + id: obj["id"] as int, + schoolname: obj["schoolname"] as String, + fullname: obj["fullname"] as String, + description: obj["description"] as String?, + chatserverId: obj["chatserver_id"] as int?, + ); + } + + static Future getByID(Session? session, int id) async { + final data = await backend.query("school/get", {"id": id}, session); + if (data["status"] < 0) { + throw data["message"]; + } + return School.fromJson(data["school"] as Map); + } + + Future getUserById(Session? session, int userId) { + return SchoolUser.getFromIds(session, id, userId); + } +} diff --git a/lib/models/school_user.dart b/lib/models/school_user.dart new file mode 100644 index 0000000..074619c --- /dev/null +++ b/lib/models/school_user.dart @@ -0,0 +1,70 @@ +import 'package:ins/models/class.dart'; + +import 'school.dart'; +import 'user.dart'; +import 'session.dart'; +import 'userrole.dart'; +import 'model.dart'; +import 'package:ins/backend.dart' as backend; + +class SchoolUser implements Model { + final int schoolId; + final int userId; + final int? classId; + final UserRole role; + const SchoolUser({ + required this.schoolId, + required this.userId, + required this.classId, + required this.role, + }); + factory SchoolUser.fromJson(Map data) { + return SchoolUser( + schoolId: data["school_id"] as int, + userId: data["user_id"] as int, + classId: data["class_id"] as int?, + role: (data["role"] as String).toUserRole(), + ); + } + @override + Map toJson() { + return { + "school_id": schoolId, + "user_id": userId, + "class_id": classId, + "role": role.toJson(), + }; + } + + Future getSchool(Session? session) { + return School.getByID(session, schoolId); + } + + Future getUser(Session? session) { + return User.getByID(session, userId); + } + + static Future getFromIds( + Session? session, + int schoolId, + int userId, + ) async { + final data = await backend.query("v1/school/getuser", { + "user_id": userId, + "school_id": userId, + }, session); + if ((data["status"] as int) < 0) { + if ((data["status"] as int) == backend.Status.notFound) { + return null; + } else { + throw data["message"] as String; + } + } + return SchoolUser.fromJson(data["user"] as Map); + } + + Future getClass(Session? session) async { + if (classId == null) return null; + return await Class.getById(session, classId!); + } +} diff --git a/lib/models/session.dart b/lib/models/session.dart index 15de766..92c5fe0 100644 --- a/lib/models/session.dart +++ b/lib/models/session.dart @@ -1,12 +1,12 @@ import 'model.dart'; import 'user.dart'; -class Session extends Model { +class Session implements Model { final int id; final int userId; final DateTime createdAt; final String token; - Session({ + const Session({ required this.id, required this.userId, required this.createdAt, @@ -30,7 +30,7 @@ class Session extends Model { token: json['token'] as String, ); } - Future getUser() { - return User.getByID(userId); + Future getUser(Session? session) { + return User.getByID(session, userId); } } diff --git a/lib/models/thread.dart b/lib/models/thread.dart new file mode 100644 index 0000000..addb729 --- /dev/null +++ b/lib/models/thread.dart @@ -0,0 +1,54 @@ +import 'package:ins/models/chatmessage.dart'; + +import 'model.dart'; +import 'session.dart'; +import 'package:ins/backend.dart' as backend; + +class Thread implements Model { + final int id; + final int? parentThreadId; + const Thread({required this.id, required this.parentThreadId}); + factory Thread.fromJson(Map data) { + return Thread( + id: data['id'] as int, + parentThreadId: data['parent_thread_id'] as int?, + ); + } + @override + Map toJson() { + return {"id": id, "parent_thread_id": parentThreadId}; + } + + static Future getByID(Session? session, int id) async { + final data = await backend.query("v1/thread/get", { + "id": id.toString(), + }, session); + if (data["status"] < 0) { + throw Exception("Failed to get chat thread by ID: ${data["message"]}"); + } + return Thread.fromJson(data["thread"] as Map); + } + + Future getParent(Session? session) async { + if (parentThreadId == null) return null; + return await Thread.getByID(session, parentThreadId!); + } + + Future sendMessage( + Session? session, + String content, + int? repliesTo, + ) async { + final response = await backend.query("v1/message/send", { + "content": content, + "thread_id": id, + if (repliesTo != null) ...{"replies_to": repliesTo.toString()}, + }, session); + if (response['status'] as int < 0) { + throw Exception( + "Error sending message: ${response['message'] as String}", + ); + } + return Chatmessage.fromJson(response['message'] as Map); + } +} diff --git a/lib/models/user.dart b/lib/models/user.dart index d312052..91b563a 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -1,14 +1,17 @@ +import 'package:ins/models/school_user.dart'; + import 'model.dart'; import 'package:ins/netimage.dart'; import 'package:ins/backend.dart' as backend; +import 'session.dart'; -class User extends Model { +class User implements Model { final int id; final String username; final String email; final String fullname; final NetImage? profile; - User({ + const User({ required this.id, required this.username, required this.email, @@ -37,11 +40,51 @@ class User extends Model { }; } - static Future getByID(int id) async { - final data = await backend.query("user/get", {"id": id.toString()}, null); + static Future getByID(Session? session, int id) async { + final data = await backend.query("v1/user/get", { + "id": id.toString(), + }, session); if (data["status"] < 0) { throw Exception("Failed to get user by ID: ${data["message"]}"); } return User.fromJson(data["user"] as Map); } + + static Future usernameInfo(String name) async { + final data = await backend.query("v1/username/info", { + "username": name, + }, null); + if (data['status'] < 0) { + throw Exception("Failed to check username: ${data['message']}"); + } + return UsernameInfo.fromJson(data['info'] as Map); + } + + Future getSchoolUserBySchoolID(Session? session, int schoolId) { + return SchoolUser.getFromIds(session, schoolId, id); + } + + Future> getSchoolUsers(Session? session) async { + final data = await backend.query("v1/user/getchoolusers", { + "user_id": id, + }, session); + if (data['status'] as int < 0) { + throw "Error getting school users ${data['message'] as String}"; + } + return (data["users"] as List>) + .map(SchoolUser.fromJson) + .toList(); + } +} + +class UsernameInfo { + final bool isValid; + final bool isTaken; + const UsernameInfo({required this.isValid, required this.isTaken}); + factory UsernameInfo.fromJson(Map json) { + return UsernameInfo( + isValid: json['is_valid'] as bool, + isTaken: json['is_taken'] as bool, + ); + } } diff --git a/lib/models/userrole.dart b/lib/models/userrole.dart new file mode 100644 index 0000000..39e862e --- /dev/null +++ b/lib/models/userrole.dart @@ -0,0 +1,33 @@ +enum UserRole { student, teacher, admin, staff } + +extension UserRoleExtension on UserRole { + String toJson() { + switch (this) { + case UserRole.student: + return "student"; + case UserRole.teacher: + return "teacher"; + case UserRole.admin: + return "admin"; + case UserRole.staff: + return "staff"; + } + } +} + +extension UserRoleStringExtension on String { + UserRole toUserRole() { + switch (toLowerCase()) { + case "student": + return UserRole.student; + case "teacher": + return UserRole.teacher; + case "admin": + return UserRole.admin; + case "staff": + return UserRole.staff; + default: + throw ArgumentError('Unknown role: $this'); + } + } +} diff --git a/lib/pages/home.dart b/lib/pages/home.dart index ce40409..3476dae 100644 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -8,13 +8,18 @@ Widget getPage() { future: AppState.load(), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { - return const LoadingWidget( - messages: [ - "Loading app state...", - "Please wait...", - "Almost there...", - ], - switchInterval: Duration(seconds: 3), + return const Scaffold( + body: LoadingWidget( + messages: [ + "Loading app state...", + "Verifying user details...", + "Getting permissions from your schools..", + "Verifying the age on your birth certificate...", + "Please wait...", + "Almost there...", + ], + switchInterval: Duration(seconds: 1), + ), ); } else if (snapshot.hasData) { final appState = snapshot.data!; diff --git a/lib/pages/sign/signup/base.dart b/lib/pages/sign/signup/base.dart index 8ac70db..7b39b24 100644 --- a/lib/pages/sign/signup/base.dart +++ b/lib/pages/sign/signup/base.dart @@ -1,3 +1,4 @@ +import 'dart:ui'; import 'package:flutter/material.dart'; class SignupAssistantBase extends StatelessWidget { @@ -5,33 +6,176 @@ class SignupAssistantBase extends StatelessWidget { final Widget body; final Function()? next; final bool showNextButton; + final String nextText; + const SignupAssistantBase({ super.key, required this.title, required this.body, this.showNextButton = false, this.next, + this.nextText = "Continue >", }); + + // Define a breakpoint for wide screens + static const double _wideScreenBreakpoint = 720.0; + @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: title, leading: BackButton()), - body: Center( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( + appBar: AppBar( + title: title, + leading: const BackButton(), + backgroundColor: Colors.transparent, // Make AppBar transparent + elevation: 0, // No shadow for AppBar + ), + extendBodyBehindAppBar: true, // Allow body to extend behind AppBar + body: Stack( + children: [ + Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - body, - if (showNextButton) ...[ - const Spacer(), - SizedBox( - width: double.infinity, - child: FilledButton( + Padding( + padding: EdgeInsetsGeometry.all(100), + child: Image.asset( + "assets/icon/is-transparent-crop.png", // Ensure this path is correct + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + // Fallback if image fails to load + return Container( + color: Theme.of(context).colorScheme.secondary, + ); + }, + ), + ), + ], + ), + + Positioned.fill( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 15), + child: Container( + // Add a subtle tint to the blur for better text contrast if needed + color: Colors.black.withOpacity(0.1), + ), + ), + ), + + // 3. Content Layer + SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final bool isWideScreen = + constraints.maxWidth > _wideScreenBreakpoint; + + if (isWideScreen) { + // Wide screen layout: Two-pane + return Row( + children: [ + // Left pane (can be empty, show another graphic, or branding) + Expanded(flex: 1, child: _buildLeftPane(context)), + // Right pane (form content) + Expanded( + flex: 1, // Adjust flex + child: Center( + // Center the card in this pane + child: _buildFormContentCard(context), + ), + ), + ], + ); + } else { + // Narrow screen layout: Single centered column + return Center(child: _buildFormContentCard(context)); + } + }, + ), + ), + ], + ), + ); + } + + Widget _buildFormContentCard(BuildContext context) { + return Padding( + // Add padding around the card itself for spacing from screen edges + padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 20.0), + child: Material( + elevation: 8.0, + borderRadius: BorderRadius.circular(16.0), + // Make card background slightly transparent to blend with blurred background + color: Theme.of(context).colorScheme.surface.withOpacity(0.85), + child: SingleChildScrollView( + // Allows content to scroll if it's too long + padding: const EdgeInsets.all(24.0), + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 400, // Max width for the form content itself + ), + child: Column( + mainAxisSize: + MainAxisSize.min, // So column takes minimum necessary height + crossAxisAlignment: + CrossAxisAlignment.stretch, // Button will stretch + children: [ + body, // Your main form widgets + if (showNextButton) ...[ + const SizedBox(height: 24.0), // Spacing before the button + FilledButton( onPressed: next, - child: const Text("Continue >"), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16.0), + textStyle: Theme.of(context).textTheme.labelLarge, + ), + child: Text(nextText), ), - ), + ], ], + ), + ), + ), + ), + ); + } + + Widget _buildLeftPane(BuildContext context) { + return SizedBox( + height: 470, + width: 400, + child: Card( + margin: EdgeInsets.zero, + shape: RoundedSuperellipseBorder( + borderRadius: BorderRadius.only( + topRight: Radius.circular(5), + bottomRight: Radius.circular(5), + ), + ), + color: Theme.of(context).colorScheme.primaryContainer.withAlpha(200), + child: Padding( + padding: EdgeInsets.symmetric(vertical: 40, horizontal: 30), + child: Column( + children: [ + Center( + child: Column( + children: [ + Text( + "Welcome to IS", + style: Theme.of(context).textTheme.displayLarge, + ), + const SizedBox(height: 30), + Text( + " Empowering connections, empowering futures ", + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSecondaryContainer.withAlpha(128), + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ), ], ), ), diff --git a/lib/pages/sign/signup/extlinked.dart b/lib/pages/sign/signup/extlinked.dart new file mode 100644 index 0000000..b8db7c5 --- /dev/null +++ b/lib/pages/sign/signup/extlinked.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'form.dart'; +import 'base.dart'; +import 'package:ins/utils/email.dart'; +import 'package:intl_phone_number_input/intl_phone_number_input.dart'; +import 'submit.dart'; +import 'package:ins/animations/page/slide.dart'; + +class ExtraLinkedPage extends StatefulWidget { + final SignupForm form; + + const ExtraLinkedPage({super.key, required this.form}); + + @override + State createState() => _ExtraLinkedPageState(); +} + +class _ExtraLinkedPageState extends State { + final TextEditingController _emailController = TextEditingController(); + final TextEditingController _phoneController = TextEditingController(); + bool _phoneValid = true; + String? _emailError; + @override + Widget build(BuildContext context) { + return SignupAssistantBase( + body: Padding( + padding: const EdgeInsets.all( + 20, + ), //const EdgeInsets.symmetric(horizontal: 20, vertical: 30), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 30), + Text( + "Optional information.", + style: Theme.of(context).textTheme.headlineLarge, + ), + const SizedBox(height: 30), + TextField( + controller: _emailController, + decoration: InputDecoration( + prefixIcon: Icon(Icons.person_outline), + labelText: "Email Address", + hintText: 'temexvironie12@ama.co', + border: OutlineInputBorder(), + suffixIcon: _emailError == null + ? const Padding( + padding: EdgeInsets.only(right: 10), + child: Icon(Icons.check_circle, color: Colors.green), + ) + : const Padding( + padding: EdgeInsets.only(right: 10), + child: Icon( + Icons.cancel_rounded, + color: Colors.green, + ), + ), + error: _buildEmailError(), + ), + autofocus: true, + keyboardType: TextInputType.emailAddress, + autocorrect: false, + onChanged: (_) { + verifyEmail(); + }, + ), + const SizedBox(height: 30), + InternationalPhoneNumberInput( + onInputChanged: (PhoneNumber number) { + setState(() { + _phoneValid = + number.phoneNumber == null || + number.phoneNumber!.length == 9 + 4; + }); + }, + onInputValidated: (bool value) { + setState(() { + //_phoneValid = value; + }); + }, + ignoreBlank: true, + textFieldController: _phoneController, + formatInput: true, + keyboardType: const TextInputType.numberWithOptions( + signed: true, + decimal: true, + ), + inputDecoration: InputDecoration( + labelText: 'Phone Number', + border: OutlineInputBorder(), + error: _phoneValid + ? null + : Text( + "Phone number should be 9 digits long (without country code)", + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Colors.red), + ), + ), + countries: ["CM"], + ), + ], + ), + ), + ), + title: const Text("Add email and/or phone number"), + showNextButton: true, + next: + (_emailError == null || _emailController.text.isEmpty) && + (_phoneValid || _phoneController.text.isEmpty) + ? () => _next(context) + : null, + ); + } + + void verifyEmail() { + setState(() { + if (_emailController.text.isEmpty) { + _emailError = null; + } else { + _emailError = chechEmail(_emailController.text); + } + }); + } + + Widget? _buildEmailError() { + return _emailController.text.isNotEmpty && _emailError != null + ? Text( + _emailError!, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Colors.red), + ) + : null; + } + + void _next(BuildContext context) { + if (_emailController.text.isNotEmpty) { + widget.form.email = _emailController.text; + } + if (_phoneController.text.isNotEmpty) { + widget.form.phone = _phoneController.text; + } + Navigator.of( + context, + ).push(SlidePageRoute(child: SubmitingPage(form: widget.form))); + } +} diff --git a/lib/pages/sign/signup/form.dart b/lib/pages/sign/signup/form.dart index fa23859..5a0db14 100644 --- a/lib/pages/sign/signup/form.dart +++ b/lib/pages/sign/signup/form.dart @@ -1,7 +1,41 @@ +import 'package:ins/models.dart' as models; +import 'package:ins/appstate.dart'; +import 'package:ins/backend.dart' as backend; + class SignupForm { String? username; String? email; String? fullname; String? password; String? phone; + Future submit() async { + final result = await backend.query("signup", { + "username": username, + "email": email, + "fullname": fullname, + "password": password, + "phone": phone, + }, null); + if (result["status"] < 0) { + throw result["message"]; + } + return SignupFormSubmitResult( + session: models.Session.fromJson( + result["session"] as Map, + ), + user: models.User.fromJson(result["user"] as Map), + ); + } +} + +class SignupFormSubmitResult { + final models.Session session; + final models.User user; + const SignupFormSubmitResult({required this.session, required this.user}); + Future saveInState() async { + AppState state = await AppState.load(); + state.session = session; + state.user = user; + state.save(); + } } diff --git a/lib/pages/sign/signup/launcher.dart b/lib/pages/sign/signup/launcher.dart index 796b87b..a5a83f0 100644 --- a/lib/pages/sign/signup/launcher.dart +++ b/lib/pages/sign/signup/launcher.dart @@ -1,12 +1,10 @@ import 'package:flutter/material.dart'; import 'namepassword.dart'; import 'form.dart'; +import 'package:ins/animations/page/slide.dart'; void launchSignupAssistant(BuildContext context) { SignupForm form = SignupForm(); - Navigator.push( - context, - MaterialPageRoute(builder: (context) => NamePasswordPage(form: form)), - ); + Navigator.push(context, SlidePageRoute(child: NamePasswordPage(form: form))); } diff --git a/lib/pages/sign/signup/namepassword.dart b/lib/pages/sign/signup/namepassword.dart index fd197ab..b975b56 100644 --- a/lib/pages/sign/signup/namepassword.dart +++ b/lib/pages/sign/signup/namepassword.dart @@ -1,7 +1,14 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'form.dart'; +import 'package:ins/animations/page/slide.dart'; +import 'package:ins/models.dart' as models; +import 'package:ins/utils/username.dart'; + import 'base.dart'; +import 'form.dart'; +import 'extlinked.dart'; class NamePasswordPage extends StatefulWidget { final SignupForm form; @@ -14,121 +21,227 @@ class NamePasswordPage extends StatefulWidget { class _NamePasswordPageState extends State { final TextEditingController _nameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); final TextEditingController _passwordController = TextEditingController(); final TextEditingController _password2Controller = TextEditingController(); + String? _usernameCheckError; bool _obscureText = true; bool _isPasswordValid = false; bool _isNameValid = false; bool _arePasswordMatching = false; - - @override - void initState() { - super.initState(); - _nameController.addListener(verifyEntries); - _passwordController.addListener(verifyEntries); - _password2Controller.addListener(verifyEntries); - } + bool _isUserNameAvailable = false; + bool _isCheckingUsername = false; + bool _wasUsernameEdited = false; @override Widget build(BuildContext context) { return SignupAssistantBase( body: Padding( padding: const EdgeInsets.all( - 0, + 20, ), //const EdgeInsets.symmetric(horizontal: 20, vertical: 30), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 20), - Text( - "Create Your Profile", - style: Theme.of(context).textTheme.headlineLarge, - ), - const SizedBox(height: 30), - TextField( - controller: _nameController, - decoration: const InputDecoration( - labelText: "Full Name", - hintText: 'John Doe', - border: OutlineInputBorder(), - ), - autofocus: true, - keyboardType: TextInputType.name, - textCapitalization: TextCapitalization.words, - autocorrect: false, - ), - const SizedBox(height: 20), - TextField( - controller: _passwordController, - decoration: InputDecoration( - labelText: "Password", - border: const OutlineInputBorder(), - suffixIcon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: Icon( - _obscureText ? Icons.visibility : Icons.visibility_off, - ), - onPressed: () => - setState(() => _obscureText = !_obscureText), - ), - ], + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _nameController, + decoration: const InputDecoration( + prefixIcon: Icon(Icons.person), + labelText: "Full Name", + hintText: 'Temex Vironie', + border: OutlineInputBorder(), + helperText: "Your private name, not shown to others", ), + autofocus: true, + keyboardType: TextInputType.name, + textCapitalization: TextCapitalization.words, + autocorrect: false, + onChanged: (_) { + if (!_wasUsernameEdited) generateUsername(); + }, ), - obscureText: _obscureText, - autofocus: true, - keyboardType: TextInputType.visiblePassword, - textCapitalization: TextCapitalization.none, - inputFormatters: [ - FilteringTextInputFormatter.deny(RegExp(r'\s')), - ], - ), - if (_passwordController.text.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: _buildPasswordStatus(), + const SizedBox(height: 20), + TextField( + controller: _usernameController, + decoration: InputDecoration( + prefixIcon: Icon(Icons.person_outline), + labelText: "Username", + hintText: 'temexvironie12', + border: OutlineInputBorder(), + helperText: "A short, public username for your profile", + suffixIcon: _isCheckingUsername + ? const Padding( + padding: EdgeInsets.all(8.0), + child: CircularProgressIndicator(strokeWidth: 2), + ) + : _isUserNameAvailable + ? const Padding( + padding: EdgeInsets.only(right: 10), + child: Icon(Icons.check_circle, color: Colors.green), + ) + : null, + error: _buildUsernameError(), + ), + autofocus: true, + keyboardType: TextInputType.text, + textCapitalization: TextCapitalization.none, + autocorrect: false, + inputFormatters: [ + FilteringTextInputFormatter( + " ", + replacementString: "_", + allow: false, + ), + FilteringTextInputFormatter.deny(RegExp(r'[^a-z0-9_]')), + ], + onChanged: (_) { + setState(() { + _wasUsernameEdited = true; + verifyNames(); + }); + }, ), - const SizedBox(height: 20), - TextField( - controller: _password2Controller, - decoration: InputDecoration( - labelText: "Reenter password", - border: const OutlineInputBorder(), - suffixIcon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _arePasswordMatching - ? Icon(Icons.check_circle, color: Colors.green) - : Icon(Icons.cancel_rounded, color: Colors.red), - ], + + const SizedBox(height: 30), + TextField( + controller: _passwordController, + decoration: InputDecoration( + prefixIcon: Icon(Icons.lock), + labelText: "Password", + border: const OutlineInputBorder(), + error: _buildPasswordError(), + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: Icon( + !_obscureText + ? Icons.visibility + : Icons.visibility_off, + ), + onPressed: () => + setState(() => _obscureText = !_obscureText), + ), + SizedBox(width: 8), + ], + ), ), + obscureText: _obscureText, + autofocus: true, + keyboardType: TextInputType.visiblePassword, + textCapitalization: TextCapitalization.none, ), - obscureText: _obscureText, - autofocus: true, - keyboardType: TextInputType.visiblePassword, - textCapitalization: TextCapitalization.none, - inputFormatters: [ - FilteringTextInputFormatter.deny(RegExp(r'\s')), - ], - ), - if (_password2Controller.text.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: _buildPassword2Status(), + const SizedBox(height: 20), + TextField( + controller: _password2Controller, + decoration: InputDecoration( + prefixIcon: Icon(Icons.lock), + labelText: "Reenter password", + border: const OutlineInputBorder(), + error: _password2Controller.text.isNotEmpty + ? _buildPassword2Error() + : null, + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_password2Controller.text.isNotEmpty) + if (_arePasswordMatching) + Icon(Icons.check_circle, color: Colors.green) + else + Icon(Icons.cancel_rounded, color: Colors.red), + ], + ), + ), + obscureText: _obscureText, + autofocus: true, + keyboardType: TextInputType.visiblePassword, + textCapitalization: TextCapitalization.none, ), - ], + ], + ), ), ), title: const Text("Profile Setup"), showNextButton: true, - next: (_isNameValid && _isPasswordValid && _arePasswordMatching) - ? () => gotoNext() - : null, + next: !(_isNameValid && _isPasswordValid && _arePasswordMatching) + ? null + : () => _next(context), ); } - Widget _buildPasswordStatus() { + void generateUsername() { + final name = _nameController.text; + _usernameController.text = formatUsername(name); + } + + @override + void initState() { + super.initState(); + _nameController.addListener(verifyNames); + _usernameController.addListener(verifyUsername); + _passwordController.addListener(verifyPasswords); + _password2Controller.addListener(verifyPasswords); + } + + void verifyNames() { + verifyUsername(); + setState(() { + _isNameValid = _nameController.text.isNotEmpty; + _arePasswordMatching = + _passwordController.text == _password2Controller.text; + }); + } + + void verifyPasswords() { + setState(() { + _isPasswordValid = _passwordController.text.length >= 8; + _arePasswordMatching = + _passwordController.text == _password2Controller.text; + }); + } + + Future verifyUsername() async { + if (_isCheckingUsername || _usernameController.text.isEmpty) { + return; // Prevent multiple checks + } + setState(() { + _isCheckingUsername = true; + }); + await Future.delayed(const Duration(milliseconds: 2000)); + try { + final usenameInfo = await models.User.usernameInfo( + _usernameController.text, + ); + setState(() { + _isUserNameAvailable = !usenameInfo.isTaken; + _usernameCheckError = null; + _isCheckingUsername = false; + }); + } catch (e) { + setState(() { + _usernameCheckError = e.toString(); + _isUserNameAvailable = false; + _isCheckingUsername = false; + }); + } + } + + Widget? _buildPassword2Error() { + if (_password2Controller.text.isEmpty) return null; + if (!_arePasswordMatching) { + return Text( + "Password do not match", + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Colors.red), + ); + } + return null; + } + + Widget? _buildPasswordError() { + if (_passwordController.text.isEmpty) return null; if (!_isPasswordValid) { return Text( "Password must have atleast 8 characters", @@ -137,29 +250,47 @@ class _NamePasswordPageState extends State { ).textTheme.bodySmall?.copyWith(color: Colors.red), ); } - return const SizedBox.shrink(); + return null; } - Widget _buildPassword2Status() { - if (!_arePasswordMatching) { + Widget? _buildUsernameError() { + if (_usernameController.text.isEmpty) return null; + if (_usernameCheckError != null) { return Text( - "Password do not match", + _usernameCheckError!, style: Theme.of( context, ).textTheme.bodySmall?.copyWith(color: Colors.red), ); } - return const SizedBox.shrink(); + final username = _usernameController.text; + if (_wasUsernameEdited) { + if (username.length < 3 || username.length > 20) { + return Text( + "Username must be between 3 and 20 characters", + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Colors.red), + ); + } + } + if (!_isUserNameAvailable) { + return Text( + "Username is already taken", + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Colors.red), + ); + } + return null; } - void gotoNext() {} - - void verifyEntries() { - setState(() { - _isPasswordValid = _passwordController.text.length >= 8; - _isNameValid = _nameController.text.isNotEmpty; - _arePasswordMatching = - _passwordController.text == _password2Controller.text; - }); + void _next(BuildContext context) { + widget.form.fullname = _nameController.text; + widget.form.username = _usernameController.text; + widget.form.password = _passwordController.text; + Navigator.of( + context, + ).push(SlidePageRoute(child: ExtraLinkedPage(form: widget.form))); } } diff --git a/lib/pages/sign/signup/submit.dart b/lib/pages/sign/signup/submit.dart new file mode 100644 index 0000000..5620bfd --- /dev/null +++ b/lib/pages/sign/signup/submit.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:ins/widgets/imsg.dart'; +import 'form.dart'; +import 'package:ins/widgets/loading.dart'; + +class SubmitingPage extends StatelessWidget { + final SignupForm form; + const SubmitingPage({super.key, required this.form}); + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Creating your account")), + body: FutureBuilder( + future: form.submit(), + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center( + child: SizedBox( + width: 500, + child: IMsgWidget( + icon: Icon( + Icons.error, + size: 200, + color: Theme.of(context).colorScheme.error, + ), + message: Text( + snapshot.error!.toString(), + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + actions: FilledButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text("Retry"), + ), + ), + ), + ); + } else if (snapshot.hasData) { + final result = snapshot.data!; + result.saveInState(); + return Center( + child: SizedBox( + width: 400, + child: IMsgWidget( + icon: Icon( + Icons.check_circle, + size: 200, + color: Colors.greenAccent, + ), + message: Text("Account created succesfully"), + actions: OutlinedButton( + onPressed: () => _openDashboard(context), + child: const Text("Open dashboard"), + ), + ), + ), + ); + } else { + return LoadingWidget( + messages: [ + "Creating your account..", + "Adding your preferences..", + "Updating our database...", + "Putting in your contact information..", + "Looking for potential reelatives...", + "Generating your dashboard", + ], + ); + } + }, + ), + ); + } + + void _openDashboard(BuildContext context) {} +} diff --git a/lib/pages/welcomepage.dart b/lib/pages/welcomepage.dart index 523eaaf..49f4eef 100644 --- a/lib/pages/welcomepage.dart +++ b/lib/pages/welcomepage.dart @@ -6,53 +6,133 @@ class WelcomePage extends StatelessWidget { @override Widget build(BuildContext context) { + final TextTheme textTheme = Theme.of(context).textTheme; + final ColorScheme colorScheme = Theme.of(context).colorScheme; + final screenWidth = MediaQuery.of(context).size.width; + + // Responsive radius for the CircleAvatar + // Clamped between a min and max radius for better control and appearance. + final double avatarRadius = (screenWidth * 0.22).clamp(70.0, 110.0); + return Scaffold( - appBar: AppBar(title: const Text('Welcome')), - body: Center( - child: SizedBox( - width: 300, - child: Stack( - alignment: Alignment.center, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircleAvatar( - backgroundImage: AssetImage('assets/icon/is.png'), - radius: 100, + appBar: AppBar( + title: const Text('Welcome'), + backgroundColor: Colors.transparent, + elevation: 0, + ), + body: SafeArea( + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 24.0, + vertical: 16.0, + ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.center, // Center children horizontally + children: [ + const Spacer(flex: 2), // Pushes content down from AppBar + + CircleAvatar( + // Ensure this asset path is correct and declared in pubspec.yaml + backgroundImage: const AssetImage('assets/icon/is.png'), + radius: avatarRadius, + backgroundColor: colorScheme + .secondaryContainer, // Fallback bg if image is transparent/fails + ), + + const SizedBox(height: 32.0), + + Text( + "Welcome to IS!", + style: textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.primary, ), - ], - ), - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - FilledButton.tonal( - onPressed: () {}, - child: Text("Connect account"), - ), - ], + textAlign: TextAlign.center, + ), + + const SizedBox(height: 12.0), + + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + "Connect an existing account or create a new one to embark on your journey with us.", + style: textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, ), - SizedBox(height: 150), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - FilledButton.tonal( - onPressed: () { - launchSignupAssistant(context); - }, - child: Text("Create account"), + ), + + const Spacer(flex: 3), + + _buildWelcomeButton( + context: context, + text: "Connect account", + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + "Connect account tapped (Not implemented)", + ), ), - ], - ), - ], - ), - ], + ); + }, + isPrimary: false, + ), + + const SizedBox(height: 16.0), + + _buildWelcomeButton( + context: context, + text: "Create account", + onPressed: () { + launchSignupAssistant(context); + }, + isPrimary: true, + ), + + const Spacer(flex: 1), // Adds some breathing room at the bottom + ], + ), ), ), ), ); } + + Widget _buildWelcomeButton({ + required BuildContext context, + required String text, + required VoidCallback onPressed, + bool isPrimary = false, + }) { + // Common styling for buttons + final baseStyle = ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14), + textStyle: Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, // Slightly bolder + fontSize: 16, // Slightly larger font + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24.0), // More rounded, modern feel + ), + minimumSize: Size(MediaQuery.of(context).size.width * 0.6, 52), + ); + + if (isPrimary) { + return FilledButton( + onPressed: onPressed, + style: baseStyle, + child: Text(text), + ); + } else { + return FilledButton.tonal( + onPressed: onPressed, + style: baseStyle, + child: Text(text), + ); + } + } } diff --git a/lib/theme.dart b/lib/theme.dart index e59b3f2..148893c 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -220,10 +220,17 @@ ThemeData _buildHarbourHazeTheme() { style: ElevatedButton.styleFrom( backgroundColor: colorScheme.primary, foregroundColor: colorScheme.onPrimary, - textStyle: originalTextTheme.labelLarge, // Already has onPrimary color - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), elevation: 2, + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14), + textStyle: originalTextTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, // Slightly bolder + fontSize: 16, // Slightly larger font + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + 24.0, + ), // More rounded, modern feel + ), ), ), textButtonTheme: TextButtonThemeData( @@ -233,7 +240,7 @@ ThemeData _buildHarbourHazeTheme() { color: colorScheme.primary, fontWeight: FontWeight.w600, ), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(25)), ), ), outlinedButtonTheme: OutlinedButtonThemeData( diff --git a/lib/utils/email.dart b/lib/utils/email.dart new file mode 100644 index 0000000..e9e315b --- /dev/null +++ b/lib/utils/email.dart @@ -0,0 +1,15 @@ +String? chechEmail(String email) { + final match = RegExp("[^a-zA-Z0-9@.]").firstMatch("email"); + if (match != null) return "Invalid character in email ${match[0]}"; + if (!email.contains("@")) return "Email should contain '@'"; + final parts = email.split("@"); + if (parts.length != 2) { + return "Email should contain 1 '@'"; + } + if (parts[0].length < 2) return "Email name too short"; + if (!parts[1].contains(".")) return "Invalid email domain(second part)"; + for (var element in parts[1].split(".")) { + if (element.isEmpty) return "Invalid domain component"; + } + return null; +} diff --git a/lib/utils/username.dart b/lib/utils/username.dart new file mode 100644 index 0000000..80bffd8 --- /dev/null +++ b/lib/utils/username.dart @@ -0,0 +1,6 @@ +String formatUsername(String name) { + return name + .toLowerCase() + .replaceAll(" ", "_") + .replaceAll(RegExp("[^a-z1-9_]+"), ""); +} diff --git a/lib/widgets/imsg.dart b/lib/widgets/imsg.dart new file mode 100644 index 0000000..381eea4 --- /dev/null +++ b/lib/widgets/imsg.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +class IMsgWidget extends StatelessWidget { + final Icon icon; + final Text message; + final Widget? actions; + const IMsgWidget({ + super.key, + required this.icon, + required this.message, + this.actions = const SizedBox.shrink(), + }); + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsetsGeometry.symmetric( + vertical: 10, + horizontal: 20, + ), + child: Column( + children: [ + SizedBox(height: 10), + icon, + SizedBox(height: 20), + message, + Spacer(), + if (actions != null) + SizedBox(width: double.infinity, child: actions!), + const SizedBox(height: 20), + ], + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index c01733a..74f3395 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -113,6 +113,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + equatable: + dependency: transitive + description: + name: equatable + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + url: "https://pub.dev" + source: hosted + version: "2.0.7" fake_async: dependency: transitive description: @@ -216,6 +224,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.4" + intl_phone_number_input: + dependency: "direct main" + description: + name: intl_phone_number_input + sha256: "1c4328713a9503ab26a1fdbb6b00b4cada68c18aac922b35bedbc72eff1297c3" + url: "https://pub.dev" + source: hosted + version: "0.7.4" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" json_annotation: dependency: transitive description: @@ -248,6 +272,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" + libphonenumber_platform_interface: + dependency: transitive + description: + name: libphonenumber_platform_interface + sha256: f801f6c65523f56504b83f0890e6dad584ab3a7507dca65fec0eed640afea40f + url: "https://pub.dev" + source: hosted + version: "0.4.2" + libphonenumber_plugin: + dependency: transitive + description: + name: libphonenumber_plugin + sha256: c615021d9816fbda2b2587881019ed595ecdf54d999652d7e4cce0e1f026368c + url: "https://pub.dev" + source: hosted + version: "0.3.3" + libphonenumber_web: + dependency: transitive + description: + name: libphonenumber_web + sha256: "8186f420dbe97c3132283e52819daff1e55d60d6db46f7ea5ac42f42a28cc2ef" + url: "https://pub.dev" + source: hosted + version: "0.3.2" lints: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 85b781f..b0f6256 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,7 @@ dependencies: flutter_launcher_icons: ^0.14.4 cached_network_image: ^3.4.1 http: ^1.4.0 + intl_phone_number_input: ^0.7.4 dev_dependencies: flutter_test: