feat: Update pubspec.yaml with new dependencies and local assets

- Updated description format in pubspec.yaml
- Added multiple utility packages for enhanced functionality
- Included local assets and fonts for better UI design
- Updated widget test to reference the new app structure
- Registered new plugins in generated_plugin_registrant.cc for Windows
- Updated generated_plugins.cmake to include new plugins
- Modified Supabase config to allow additional redirect URLs
This commit is contained in:
vergiLgood1 2025-05-16 15:42:03 +07:00
parent 636f892a42
commit 7ad427baf6
169 changed files with 11938 additions and 230 deletions

42
sigap-mobile/.env Normal file
View File

@ -0,0 +1,42 @@
# Update these with your Supabase details from your project settings > API
# https://app.supabase.com/project/_/settings/api
# Supabase Production URL
# SUPABASE_URL=https://bhfzrlgxqkbkjepvqeva.supabase.co
# SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJoZnpybGd4cWtia2plcHZxZXZhIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDY1MTU2ODUsImV4cCI6MjA2MjA5MTY4NX0.qDe8QNOON5ra6-JSQ-mhBEXdRFxoQGPPifBpB_-5FrU
# # Supabase Service Role Secret Key
# SERVICE_ROLE_SECRET="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNwcGVqcm9leW9uc3F4dWxpbmFqIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTczOTM2MTEyNiwiZXhwIjoyMDU0OTM3MTI2fQ.iYIVeUChLIcC7NRaeJ6dViI9JiUZSMUKufFsDTfAkjA"
# SUPABASE_STORAGE_URL="https://cppejroeyonsqxulinaj.supabase.co/storage/v1/object/public"
# Connect to Supabase via connection pooling
# DATABASE_URL="postgresql://postgres.bhfzrlgxqkbkjepvqeva:TA-SIGAP2024@aws-0-ap-southeast-1.pooler.supabase.com:6543/postgres?pgbouncer=true"
# Direct connection to the database. Used for migrations
# DIRECT_URL="postgresql://postgres.bhfzrlgxqkbkjepvqeva:TA-SIGAP2024@aws-0-ap-southeast-1.pooler.supabase.com:5432/postgres"
# Supabase Local URL
SUPABASE_URL=http://host.docker.internal:54321
SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
SERVICE_ROLE_SECRET=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres"
DIRECT_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres"
# RESEND_API_KEY_TES="re_WtXdegYe_Ey3yiShKfZZtjCyY1agkEaSi"
RESEND_API_KEY="re_4EyTgztQ_3istGdHQFeSTQsLBtH6oAdub"
SEND_EMAIL_HOOK_SECRET="jeroAB/CXdS721OiHV0Ac0yRcxO7eNihgjblH62xMhLBNc6OwK3DQnkbrHjTlSw5anml2onNTolG3SzZ"
# db connection string
# Connect to Supabase via connection pooling with Supavisor.
# Direct connection to the database. Used for migrations.
# DIRECT_URL="postgresql://prisma.cppejroeyonsqxulinaj:prisma@aws-0-ap-southeast-1.pooler.supabase.com:5432/postgres"
DENO_ENV=development
MAPBOX_ACCESS_TOKEN="pk.eyJ1IjoidmVyZ2lsZ29vZDEiLCJhIjoiY205b254eGltMGJ5dzJqb2F4cGpsZXlpNSJ9.zxmnSQxuc5NBwiFpsTGJCg"
MAPBOX_TILESET_ID="vergilgood1.cm9x176pl09k11ope7hzkij0r-06afz"
NODE_ENV=development

5
sigap-mobile/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"recommendations": [
"denoland.vscode-deno"
]
}

25
sigap-mobile/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,25 @@
{
"[typescript]": {
"editor.defaultFormatter": "denoland.vscode-deno"
},
"deno.enablePaths": [
"supabase/functions"
],
"deno.lint": true,
"deno.unstable": [
"bare-node-builtins",
"byonm",
"sloppy-imports",
"unsafe-proto",
"webgpu",
"broadcast-channel",
"worker-options",
"cron",
"kv",
"ffi",
"fs",
"http",
"net"
],
"files.autoSave": "afterDelay"
}

View File

@ -7,6 +7,9 @@
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
analyzer:
errors:
unused_element: ignore
include: package:flutter_lints/flutter.yaml
linter:

View File

@ -1,35 +1,60 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="sigap"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Required to fetch data from the internet. -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- ... -->
<application android:label="sigap"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<meta-data android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Add this intent-filter for Deep Links -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- Accepts URIs that begin with YOUR_SCHEME://YOUR_HOST -->
<data android:scheme="io.supabase.flutterquickstart"
android:host="signin" />
<data android:scheme="io.supabase.flutterquickstart"
android:host="signup" />
<data android:scheme="io.supabase.flutterquickstart"
android:host="forgotpassword" />
<data android:scheme="io.supabase.flutterquickstart"
android:host="resetpassword" />
<data android:scheme="io.supabase.flutterquickstart"
android:host="verifyemail" />
<data android:scheme="io.supabase.flutterquickstart"
android:host="verifyphone" />
<data android:scheme="io.supabase.flutterquickstart"
android:host="verifyemailotp" />
<data android:scheme="io.supabase.flutterquickstart"
android:host="verifyphoneotp" />
<data android:scheme="io.supabase.flutterquickstart"
android:host="verifyemailchange" />
<data android:scheme="io.supabase.flutterquickstart"
android:host="verifyphonechange" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<meta-data android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
@ -38,8 +63,8 @@
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
</queries>
</manifest>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

@ -1,49 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Sigap</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>sigap</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Sigap</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>sigap</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true />
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true />
<key>UIApplicationSupportsIndirectInputEvents</key>
<true />
<!-- Add this array for Deep Links -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>io.supabase.flutterquickstart</string>
</array>
</dict>
</array>
<!-- ... other tags -->
</dict>
</plist>

31
sigap-mobile/lib/app.dart Normal file
View File

@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:get/get_navigation/src/root/get_material_app.dart';
import 'package:sigap/src/cores/bindings/general_bindings.dart';
import 'package:sigap/src/cores/routes/app_pages.dart';
import 'package:sigap/src/utils/constants/colors.dart';
import 'package:sigap/src/utils/constants/text_strings.dart';
import 'package:sigap/src/utils/theme/theme.dart';
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return GetMaterialApp(
title: TTexts.appName,
themeMode: ThemeMode.system,
theme: TAppTheme.lightTheme,
darkTheme: TAppTheme.darkTheme,
debugShowCheckedModeBanner: false,
initialBinding: GeneralBindings(),
localizationsDelegates: GlobalMaterialLocalizations.delegates,
supportedLocales: const [Locale('id', '')],
getPages: AppPages.routes,
home: const Scaffold(
backgroundColor: TColors.primary,
body: Center(child: CircularProgressIndicator(color: TColors.white)),
),
);
}
}

View File

@ -1,122 +1,39 @@
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_native_splash/flutter_native_splash.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:sigap/app.dart';
import 'package:sigap/src/cores/services/supabase_service.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
// This is the theme of your application.
//
// TRY THIS: Try running your application with "flutter run". You'll see
// the application has a purple toolbar. Then, without quitting the app,
// try changing the seedColor in the colorScheme below to Colors.green
// and then invoke "hot reload" (save your changes or press the "hot
// reload" button in a Flutter-supported IDE, or press "r" if you used
// the command line to start the app).
//
// Notice that the counter didn't reset back to zero; the application
// state is not lost during the reload. To reset the state, use hot
// restart instead.
//
// This works for code too, not just values: Most code changes can be
// tested with just a hot reload.
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
// This widget is the home page of your application. It is stateful, meaning
// that it has a State object (defined below) that contains fields that affect
// how it looks.
// This class is the configuration for the state. It holds the values (in this
// case the title) provided by the parent (in this case the App widget) and
// used by the build method of the State. Fields in a Widget subclass are
// always marked "final".
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
// This call to setState tells the Flutter framework that something has
// changed in this State, which causes it to rerun the build method below
// so that the display can reflect the updated values. If we changed
// _counter without calling setState(), then the build method would not be
// called again, and so nothing would appear to happen.
_counter++;
});
}
@override
Widget build(BuildContext context) {
// This method is rerun every time setState is called, for instance as done
// by the _incrementCounter method above.
//
// The Flutter framework has been optimized to make rerunning build methods
// fast, so that you can just rebuild anything that needs updating rather
// than having to individually change instances of widgets.
return Scaffold(
appBar: AppBar(
// TRY THIS: Try changing the color here to a specific color (to
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
// change color while the other colors stay the same.
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
// Here we take the value from the MyHomePage object that was created by
// the App.build method, and use it to set our appbar title.
title: Text(widget.title),
),
body: Center(
// Center is a layout widget. It takes a single child and positions it
// in the middle of the parent.
child: Column(
// Column is also a layout widget. It takes a list of children and
// arranges them vertically. By default, it sizes itself to fit its
// children horizontally, and tries to be as tall as its parent.
//
// Column has various properties to control how it sizes itself and
// how it positions its children. Here we use mainAxisAlignment to
// center the children vertically; the main axis here is the vertical
// axis because Columns are vertical (the cross axis would be
// horizontal).
//
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
// action in the IDE, or press "p" in the console), to see the
// wireframe for each widget.
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text('You have pushed the button this many times:'),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
), // This trailing comma makes auto-formatting nicer for build methods.
);
}
Future<void> main() async {
// Ensure that the Flutter binding is initialized before calling any Flutter
final WidgetsBinding widgetBinding =
WidgetsFlutterBinding.ensureInitialized();
// -- GetX Local Storage
await GetStorage.init();
// -- Await splash until other item load
FlutterNativeSplash.preserve(widgetsBinding: widgetBinding);
// Initialize the authentication repository with Supabase
await Supabase.initialize(
url: dotenv.env['SUPABASE_URL']!,
anonKey: dotenv.env['SUPABASE_ANON_KEY']!,
authOptions: const FlutterAuthClientOptions(
authFlowType: AuthFlowType.pkce,
// detectSessionInUri: true,
),
realtimeClientOptions: RealtimeClientOptions(
logLevel: RealtimeLogLevel.info,
),
storageOptions: const StorageClientOptions(retryAttempts: 10),
);
// Add this to your dependencies initialization:
await Get.putAsync(() => SupabaseService().init());
runApp(const App());
}

View File

View File

@ -0,0 +1,45 @@
import 'package:get/get.dart';
import 'package:sigap/src/features/auth/controllers/forgot_password_controller.dart';
import 'package:sigap/src/features/auth/controllers/signin_controller.dart';
import 'package:sigap/src/features/auth/controllers/signup_controller.dart';
import 'package:sigap/src/features/onboarding/controllers/choose_role_controller.dart';
import 'package:sigap/src/features/onboarding/controllers/onboarding_controller.dart';
// Onboarding controller bindings
class OnboardingControllerBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<OnboardingController>(() => OnboardingController());
}
}
class ChooseRoleControllerBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<ChooseRoleController>(() => ChooseRoleController());
}
}
// Auth controller bindings
class SignInControllerBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<SignInController>(() => SignInController());
}
}
class SignUpControllerBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<SignUpController>(() => SignUpController());
}
}
class ForgotPasswordControllerBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<ForgotPasswordController>(() => ForgotPasswordController());
}
}
// Main controller bindings

View File

@ -0,0 +1,13 @@
import 'package:get/get.dart';
import 'package:logger/logger.dart';
import 'package:sigap/src/utils/helpers/network_manager.dart';
class GeneralBindings extends Bindings {
Logger? get logger => Logger();
@override
void dependencies() {
Get.put(NetworkManager());
Get.put(logger);
}
}

View File

@ -0,0 +1,430 @@
import 'package:flutter/services.dart';
import 'package:flutter_native_splash/flutter_native_splash.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:sigap/src/cores/services/supabase_service.dart';
import 'package:sigap/src/features/auth/screens/signin/signin_screen.dart';
import 'package:sigap/src/features/onboarding/screens/onboarding/onboarding_screen.dart';
import 'package:sigap/src/utils/exceptions/exceptions.dart';
import 'package:sigap/src/utils/exceptions/format_exceptions.dart';
import 'package:sigap/src/utils/exceptions/platform_exceptions.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
class AuthenticationRepository extends GetxController {
static AuthenticationRepository get instance => Get.find();
// Variable
final storage = GetStorage();
final _supabase = SupabaseService.instance.client;
// Getters that use the Supabase service
User? get authUser => SupabaseService.instance.currentUser;
String? get currentUserId => SupabaseService.instance.currentUserId;
// get isSessionExpired => authUser?.isExpired;
@override
void onReady() {
FlutterNativeSplash.remove();
screenRedirect();
// storage.remove('TEMP_ROLE');
}
screenRedirect() async {
final user = _supabase.auth.currentUser;
if (user != null) {
// local storage
storage.writeIfNull('isFirstTime', true);
// check if user is already logged in
storage.read('isFirstTime') != true
? Get.offAll(() => const SignInScreen())
: Get.offAll(() => const OnboardingScreen());
}
}
// ----------------- Email and Password Sign In -----------------
Future<AuthResponse> signInWithEmailPassword({
required String email,
required String password,
}) async {
try {
final response = await _supabase.auth.signInWithPassword(
email: email,
password: password,
);
return response;
} on AuthException catch (e) {
throw TExceptions(e.message);
} on FormatException catch (_) {
throw const TFormatException();
} on PlatformException catch (e) {
throw TPlatformException(e.code).message;
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code ?? 'unknown_error');
} catch (e) {
throw TExceptions('Something went wrong. Please try again later.');
}
}
// [SESSION] - CHECK SESSION
Future<Map<String, dynamic>?> getSession() async {
try {
final session = _supabase.auth.currentSession;
if (session != null) {
return {'user': session.user, 'session': session};
}
return null;
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code ?? 'unknown_error');
} catch (e) {
throw TExceptions('Something went wrong. Please try again later.');
}
}
// [Email Verification] - EMAIL VERIFICATION
Future<void> sendEmailVerification() async {
try {
await _supabase.auth.resend(
type: OtpType.signup,
email: _supabase.auth.currentUser!.email,
);
} on AuthException catch (e) {
throw TExceptions(e.message);
} on FormatException catch (_) {
throw const TFormatException();
} on PlatformException catch (e) {
throw TPlatformException(e.code).message;
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code ?? 'unknown_error');
} catch (e) {
throw TExceptions('Something went wrong. Please try again later.');
}
}
// [Email Reset Password ] - RESET PASSWORD
Future<void> sendOtpResetPassword(String email) async {
try {
await _supabase.auth.resetPasswordForEmail(email);
} on AuthException catch (e) {
throw TExceptions(e.message);
} on FormatException catch (_) {
throw const TFormatException();
} on PlatformException catch (e) {
throw TPlatformException(e.code).message;
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code ?? 'unknown_error');
} catch (e) {
throw TExceptions('Something went wrong. Please try again later.');
}
}
// Compare OTP
Future<AuthResponse> verifyOtp(String otp) async {
try {
final AuthResponse res = await _supabase.auth.verifyOTP(
type: OtpType.signup,
token: otp,
);
final Session? session = res.session;
final User? user = res.user;
if (session == null && user == null) {
throw TExceptions('Failed to verify OTP. Please try again.');
}
return res;
} on AuthException catch (e) {
throw TExceptions(e.message);
} on FormatException catch (_) {
throw const TFormatException();
} on PlatformException catch (e) {
throw TPlatformException(e.code).message;
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code ?? 'unknown_error');
} catch (e) {
throw TExceptions('Something went wrong. Please try again later.');
}
}
// Update password after reset
Future<UserResponse> updatePasswordAfterReset(String newPassword) async {
try {
final response = await _supabase.auth.updateUser(
UserAttributes(password: newPassword),
);
return response;
} on AuthException catch (e) {
throw TExceptions(e.message);
} on FormatException catch (_) {
throw const TFormatException();
} on PlatformException catch (e) {
throw TPlatformException(e.code).message;
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code ?? 'unknown_error');
} catch (e) {
throw TExceptions('Something went wrong. Please try again later.');
}
}
Future<String> changePassword(
String currentPassword,
String newPassword,
String email,
) async {
try {
// Reauthenticate user
await _supabase.auth.signInWithPassword(
email: email,
password: currentPassword,
);
// Update password
await _supabase.auth.updateUser(UserAttributes(password: newPassword));
// Password changed successfully
return 'Password changed successfully.';
} on AuthException catch (e) {
throw TExceptions(e.message);
} on FormatException catch (_) {
throw const TFormatException();
} on PlatformException catch (e) {
throw TPlatformException(e.code).message;
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code ?? 'unknown_error');
} catch (e) {
throw TExceptions('Something went wrong. Please try again later.');
}
}
// [Email Verification] - CREATE NEW VERIFICATION USER EMAIL
Future<void> resendEmailVerification(String email) async {
try {
await _supabase.auth.resend(type: OtpType.signup, email: email);
} on AuthException catch (e) {
throw TExceptions(e.message);
} on FormatException catch (_) {
throw const TFormatException();
} on PlatformException catch (e) {
throw TPlatformException(e.code).message;
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code ?? 'unknown_error');
} catch (e) {
throw TExceptions('Something went wrong. Please try again later.');
}
}
// ----------------- Social Sign In -----------------
// [GoogleAuthentication] - GOOGLE
Future<bool> signInWithGoogle() async {
try {
return await _supabase.auth.signInWithOAuth(
OAuthProvider.google,
authScreenLaunchMode: LaunchMode.inAppWebView,
);
} on AuthException catch (e) {
throw TExceptions(e.message);
} on FormatException catch (_) {
throw const TFormatException();
} on PlatformException catch (e) {
throw TPlatformException(e.code).message;
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code ?? 'unknown_error');
} catch (e) {
throw TExceptions('Something went wrong. Please try again later.');
}
}
// [FacebookAuthentication] - FACEBOOK
Future<bool> signInWithFacebook() async {
try {
return await _supabase.auth.signInWithOAuth(
OAuthProvider.facebook,
authScreenLaunchMode: LaunchMode.inAppWebView,
);
} on AuthException catch (e) {
throw TExceptions(e.message);
} on FormatException catch (_) {
throw const TFormatException();
} on PlatformException catch (e) {
throw TPlatformException(e.code).message;
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code ?? 'unknown_error');
} catch (e) {
throw TExceptions('Something went wrong. Please try again later.');
}
}
// [AppleAuthentication] - APPLE
Future<bool> signInWithApple() async {
try {
return await _supabase.auth.signInWithOAuth(
OAuthProvider.apple,
authScreenLaunchMode: LaunchMode.inAppWebView,
);
} on AuthException catch (e) {
throw TExceptions(e.message);
} on FormatException catch (_) {
throw const TFormatException();
} on PlatformException catch (e) {
throw TPlatformException(e.code).message;
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code ?? 'unknown_error');
} catch (e) {
throw TExceptions('Something went wrong. Please try again later.');
}
}
// [GithubAuthentication] - GITHUB
Future<bool> signInWithGithub() async {
try {
return await _supabase.auth.signInWithOAuth(
OAuthProvider.github,
authScreenLaunchMode: LaunchMode.inAppWebView,
);
} on AuthException catch (e) {
throw TExceptions(e.message);
} on FormatException catch (_) {
throw const TFormatException();
} on PlatformException catch (e) {
throw TPlatformException(e.code).message;
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code ?? 'unknown_error');
} catch (e) {
throw TExceptions('Something went wrong. Please try again later.');
}
}
// [TwitterAuthentication] - TWITTER
Future<bool> signInWithTwitter() async {
try {
return await _supabase.auth.signInWithOAuth(
OAuthProvider.twitter,
authScreenLaunchMode: LaunchMode.inAppWebView,
);
} on AuthException catch (e) {
throw TExceptions(e.message);
} on FormatException catch (_) {
throw const TFormatException();
} on PlatformException catch (e) {
throw TPlatformException(e.code).message;
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code ?? 'unknown_error');
} catch (e) {
throw TExceptions('Something went wrong. Please try again later.');
}
}
// [Email AUTH] - SIGN UP with role selection
Future<AuthResponse> signUpWithCredential(
String email,
String password, {
Map<String, dynamic>? userMetadata,
bool isOfficer = false,
Map<String, dynamic>? officerData,
}) async {
try {
// Prepare the complete user metadata
final metadata = userMetadata ?? {};
// Add officer flag and data if needed
metadata['is_officer'] = isOfficer;
if (isOfficer && officerData != null) {
metadata['officer_data'] = officerData;
}
final authResponse = await _supabase.auth.signUp(
email: email,
password: password,
data: metadata,
);
final Session? session = authResponse.session;
final User? user = authResponse.user;
if (session == null && user == null) {
throw TExceptions('Failed to sign up. Please try again.');
}
return authResponse;
} on AuthException catch (e) {
throw TExceptions(e.message);
} on FormatException catch (_) {
throw const TFormatException();
} on PlatformException catch (e) {
throw TPlatformException(e.code).message;
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code ?? 'unknown_error');
} catch (e) {
throw TExceptions('Something went wrong. Please try again later.');
}
}
// ----------------- Logout -----------------
// [Sign Out] - SIGN OUT
Future<void> signOut() async {
try {
await _supabase.auth.signOut();
Get.offAll(() => const SignInScreen());
} on AuthException catch (e) {
throw TExceptions(e.message);
} on FormatException catch (_) {
throw const TFormatException();
} on PlatformException catch (e) {
throw TPlatformException(e.code).message;
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code ?? 'unknown_error');
} catch (e) {
throw TExceptions('Something went wrong. Please try again later.');
}
}
// ----------------- Delete Account -----------------
Future<void> deleteAccount() async {
try {
final userId = _supabase.auth.currentUser!.id;
await _supabase.rpc('delete_user', params: {'user_id': userId});
} on AuthException catch (e) {
throw TExceptions(e.message);
} on FormatException catch (_) {
throw const TFormatException();
} on PlatformException catch (e) {
throw TPlatformException(e.code).message;
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code ?? 'unknown_error');
} catch (e) {
throw TExceptions('Something went wrong. Please try again later.');
}
}
/// Updates a user's profile with the officer status and metadata
Future<UserResponse> updateUserRole({
required bool isOfficer,
Map<String, dynamic>? officerData,
}) async {
try {
// Prepare metadata with the officer flag
final userMetadata = <String, dynamic>{'is_officer': isOfficer};
// Add officer data if provided
if (isOfficer && officerData != null) {
userMetadata['officer_data'] = officerData;
}
// Update the user metadata which will trigger the role change function
final response = await _supabase.auth.updateUser(
UserAttributes(data: userMetadata),
);
return response;
} on AuthException catch (e) {
throw TExceptions(e.message);
} on FormatException catch (_) {
throw const TFormatException();
} on PlatformException catch (e) {
throw TPlatformException(e.code).message;
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code ?? 'unknown_error');
} catch (e) {
throw TExceptions('Something went wrong. Please try again later.');
}
}
}

View File

@ -0,0 +1,175 @@
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:sigap/src/cores/repositories/auth/auth_repositories.dart';
import 'package:sigap/src/features/personalization/models/index.dart';
import 'package:sigap/src/utils/exceptions/exceptions.dart';
import 'package:sigap/src/utils/exceptions/format_exceptions.dart';
import 'package:sigap/src/utils/exceptions/platform_exceptions.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
class OfficerRepository extends GetxController {
static OfficerRepository get instance => Get.find();
final _supabase = Supabase.instance.client;
// Get current user ID
String? get currentUserId => _supabase.auth.currentUser?.id;
// Get officer data
Future<OfficerModel> getOfficerData() async {
try {
if (currentUserId == null) {
throw 'User not authenticated';
}
final officerData =
await _supabase
.from('officers')
.select(
'*, roles(*), units:unit_id(*), patrol_units:patrol_unit_id(*)',
)
.eq('id', currentUserId!)
.single();
return OfficerModel.fromJson(officerData);
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code!);
} on FormatException catch (_) {
throw const TFormatException();
} on PlatformException catch (e) {
throw TPlatformException(e.code).message;
} catch (e) {
throw 'Failed to fetch officer data: ${e.toString()}';
}
}
// Update officer profile
Future<OfficerModel> updateOfficerProfile(OfficerModel officer) async {
try {
if (currentUserId == null) {
throw 'User not authenticated';
}
final updatedOfficerData = {
'name': officer.name,
'nrp': officer.nrp,
'rank': officer.rank,
'position': officer.position,
'phone': officer.phone,
'email': officer.email,
'avatar': officer.avatar,
'updated_at': DateTime.now().toIso8601String(),
};
await _supabase
.from('officers')
.update(updatedOfficerData)
.eq('id', currentUserId!);
// Also update the user's metadata to keep it in sync
final currentMetadata = _supabase.auth.currentUser?.userMetadata ?? {};
final officerDataInMetadata = currentMetadata['officer_data'] ?? {};
final updatedOfficerMetadata = {
...officerDataInMetadata,
'name': officer.name,
'nrp': officer.nrp,
'rank': officer.rank,
'position': officer.position,
'phone': officer.phone,
};
await _supabase.auth.updateUser(
UserAttributes(
data: {...currentMetadata, 'officer_data': updatedOfficerMetadata},
),
);
return await getOfficerData();
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code!);
} on AuthException catch (e) {
throw TExceptions(e.message);
} on FormatException catch (_) {
throw const TFormatException();
} on PlatformException catch (e) {
throw TPlatformException(e.code).message;
} catch (e) {
throw 'Failed to update officer profile: ${e.toString()}';
}
}
// Get officer by ID
Future<OfficerModel> getOfficerById(String officerId) async {
try {
final officerData =
await _supabase
.from('officers')
.select(
'*, roles(*), units:unit_id(*), patrol_units:patrol_unit_id(*)',
)
.eq('id', officerId)
.single();
return OfficerModel.fromJson(officerData);
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code!);
} catch (e) {
throw 'Failed to fetch officer data: ${e.toString()}';
}
}
// Convert regular user to officer
Future<void> convertToOfficer(OfficerModel officer) async {
try {
if (currentUserId == null) {
throw 'User not authenticated';
}
final authRepository = Get.put(AuthenticationRepository());
// Prepare officer data
final officerData = {
'unit_id': officer.unitId,
'nrp': officer.nrp,
'name': officer.name,
'rank': officer.rank ?? '',
'position': officer.position ?? '',
'phone': officer.phone ?? '',
'email': officer.email ?? '',
};
// Update user metadata with officer flag and data
await authRepository.updateUserRole(
isOfficer: true,
officerData: officerData,
);
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code!);
} on AuthException catch (e) {
throw TExceptions(e.message);
} catch (e) {
throw 'Failed to convert to officer: ${e.toString()}';
}
}
// Convert officer to regular user
Future<void> convertToRegularUser() async {
try {
if (currentUserId == null) {
throw 'User not authenticated';
}
final authRepository = Get.put(AuthenticationRepository());
// Update user metadata to remove officer flag
await authRepository.updateUserRole(isOfficer: false);
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code!);
} on AuthException catch (e) {
throw TExceptions(e.message);
} catch (e) {
throw 'Failed to convert to regular user: ${e.toString()}';
}
}
}

View File

@ -0,0 +1,117 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/personalization/models/profile_model.dart';
import 'package:sigap/src/utils/exceptions/exceptions.dart';
import 'package:sigap/src/utils/exceptions/format_exceptions.dart';
import 'package:sigap/src/utils/exceptions/platform_exceptions.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
class ProfileRepository extends GetxController {
static ProfileRepository get instance => Get.find();
final _supabase = Supabase.instance.client;
// Get current user ID
String? get currentUserId => _supabase.auth.currentUser?.id;
// Get user profile data
Future<ProfileModel> getProfileData() async {
try {
if (currentUserId == null) {
throw 'User not authenticated';
}
final profileData =
await _supabase
.from('profiles')
.select()
.eq('user_id', currentUserId!)
.single();
return ProfileModel.fromJson(profileData);
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code!);
} on FormatException catch (_) {
throw const TFormatException();
} on PlatformException catch (e) {
throw TPlatformException(e.code).message;
} catch (e) {
throw 'Failed to fetch profile data: ${e.toString()}';
}
}
// Update avatar
Future<String> uploadAvatar(String filePath) async {
try {
if (currentUserId == null) {
throw 'User not authenticated';
}
final fileName =
'${currentUserId}_${DateTime.now().millisecondsSinceEpoch}.jpg';
final storageResponse = await _supabase.storage
.from('avatars')
.upload(fileName, File(filePath));
final avatarUrl = _supabase.storage
.from('avatars')
.getPublicUrl(fileName);
// Update the profile with the new avatar URL
await _supabase
.from('profiles')
.update({'avatar': avatarUrl})
.eq('user_id', currentUserId!);
return avatarUrl;
} on StorageException catch (e) {
throw 'Storage error: ${e.message}';
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code!);
} catch (e) {
throw 'Failed to upload avatar: ${e.toString()}';
}
}
// Update profile information
Future<ProfileModel> updateProfile(ProfileModel profile) async {
try {
if (currentUserId == null) {
throw 'User not authenticated';
}
final updatedProfileData = profile.toJson();
await _supabase
.from('profiles')
.update(updatedProfileData)
.eq('user_id', currentUserId!);
return await getProfileData();
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code!);
} catch (e) {
throw 'Failed to update profile: ${e.toString()}';
}
}
// Get profile by user ID
Future<ProfileModel> getProfileByUserId(String userId) async {
try {
final profileData =
await _supabase
.from('profiles')
.select()
.eq('user_id', userId)
.single();
return ProfileModel.fromJson(profileData);
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code!);
} catch (e) {
throw 'Failed to fetch profile data: ${e.toString()}';
}
}
}

View File

@ -0,0 +1,51 @@
import 'package:get/get.dart';
import 'package:sigap/src/features/personalization/models/roles_model.dart';
import 'package:sigap/src/utils/exceptions/exceptions.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
class RolesRepository extends GetxController {
static RolesRepository get instance => Get.find();
final _supabase = Supabase.instance.client;
// Get all roles
Future<List<RoleModel>> getAllRoles() async {
try {
final roles = await _supabase.from('roles').select().order('name');
return (roles as List).map((role) => RoleModel.fromJson(role)).toList();
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code!);
} catch (e) {
throw 'Failed to fetch roles: ${e.toString()}';
}
}
// Get role by ID
Future<RoleModel> getRoleById(String roleId) async {
try {
final role =
await _supabase.from('roles').select().eq('id', roleId).single();
return RoleModel.fromJson(role);
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code!);
} catch (e) {
throw 'Failed to fetch role data: ${e.toString()}';
}
}
// Get role by name
Future<RoleModel> getRoleByName(String roleName) async {
try {
final role =
await _supabase.from('roles').select().eq('name', roleName).single();
return RoleModel.fromJson(role);
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code!);
} catch (e) {
throw 'Failed to fetch role data: ${e.toString()}';
}
}
}

View File

@ -0,0 +1,20 @@
import 'package:get/get.dart';
class UnitsRepository extends GetxController {
static UnitsRepository get instance => Get.find();
// Add your methods and properties here
// For example, you can add methods to fetch units, create units, etc.
// Example method to fetch all units
Future<List<UnitModel>> getAllUnits() async {
try {
final response = await _supabase.from('units').select();
return (response as List)
.map((unit) => UnitModel.fromJson(unit))
.toList();
} catch (e) {
throw 'Failed to fetch units: ${e.toString()}';
}
}
}

View File

@ -0,0 +1,151 @@
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:sigap/src/cores/services/supabase_service.dart';
import 'package:sigap/src/features/personalization/models/index.dart';
import 'package:sigap/src/utils/exceptions/exceptions.dart';
import 'package:sigap/src/utils/exceptions/format_exceptions.dart';
import 'package:sigap/src/utils/exceptions/platform_exceptions.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
class UserRepository extends GetxController {
static UserRepository get instance => Get.find();
final _supabase = SupabaseService.instance.client;
// Get current user ID
String? get currentUserId => SupabaseService.instance.currentUserId;
// Check if user is an officer
Future<bool> isCurrentUserOfficer() async {
try {
if (currentUserId == null) return false;
final user = _supabase.auth.currentUser;
if (user == null) return false;
// Check if officer flag is present in user metadata
final metadata = user.userMetadata;
if (metadata != null && metadata.containsKey('is_officer')) {
return metadata['is_officer'] == true;
}
// If no flag in metadata, check if user exists in officers table
final officerData =
await _supabase
.from('officers')
.select()
.eq('id', currentUserId!)
.single();
return officerData != null;
} on PostgrestException catch (error) {
// PGRST116 means no rows returned - not an officer
if (error.code == 'PGRST116') return false;
throw TExceptions.fromCode(error.code!);
} catch (e) {
// Default to not an officer on error
return false;
}
}
// Get user data for regular users
Future<UserModel> getUserData() async {
try {
if (currentUserId == null) {
throw 'User not authenticated';
}
final userData =
await _supabase
.from('users')
.select('*, profiles(*), role:roles(*)')
.eq('id', currentUserId!)
.single();
return UserModel.fromJson(userData);
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code!);
} on FormatException catch (_) {
throw const TFormatException();
} on PlatformException catch (e) {
throw TPlatformException(e.code).message;
} catch (e) {
throw 'Failed to fetch user data: ${e.toString()}';
}
}
// Update user profile data
Future<void> updateUserProfile({
String? firstName,
String? lastName,
String? bio,
String? address,
String? birthDate,
String? avatar,
String? username,
String? phone,
}) async {
try {
if (currentUserId == null) {
throw 'User not authenticated';
}
// First update user profile
final Map<String, dynamic> profileData = {};
if (firstName != null) profileData['first_name'] = firstName;
if (lastName != null) profileData['last_name'] = lastName;
if (bio != null) profileData['bio'] = bio;
if (address != null) profileData['address'] = address;
if (birthDate != null) profileData['birth_date'] = birthDate;
if (avatar != null) profileData['avatar'] = avatar;
if (username != null) profileData['username'] = username;
if (profileData.isNotEmpty) {
await _supabase
.from('profiles')
.update(profileData)
.eq('user_id', currentUserId!);
}
// Then update user table if needed
if (phone != null) {
await _supabase
.from('users')
.update({'phone': phone})
.eq('id', currentUserId!);
// Also update auth user phone
await _supabase.auth.updateUser(UserAttributes(phone: phone));
}
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code!);
} on AuthException catch (e) {
throw TExceptions(e.message);
} on FormatException catch (_) {
throw const TFormatException();
} on PlatformException catch (e) {
throw TPlatformException(e.code).message;
} catch (e) {
throw 'Failed to update profile: ${e.toString()}';
}
}
// Get user by ID
Future<UserModel> getUserById(String userId) async {
try {
final userData =
await _supabase
.from('users')
.select('*, profiles(*), role:roles(*)')
.eq('id', userId)
.single();
return UserModel.fromJson(userData);
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code!);
} catch (e) {
throw 'Failed to fetch user data: ${e.toString()}';
}
}
}

View File

@ -0,0 +1,57 @@
import 'package:get/get.dart';
import 'package:sigap/src/cores/bindings/controller_bindings.dart';
import 'package:sigap/src/features/auth/screens/forgot-password/forgot_password.dart';
import 'package:sigap/src/features/auth/screens/signin/signin_screen.dart';
import 'package:sigap/src/features/auth/screens/signup/signup_screen.dart';
import 'package:sigap/src/features/onboarding/screens/choose-role/choose_role_screen.dart';
import 'package:sigap/src/features/onboarding/screens/onboarding/onboarding_screen.dart';
import 'package:sigap/src/utils/constants/app_routes.dart';
class AppPages {
static const initialPage = AppRoutes.panicButton;
static final routes = [
// Onboarding
GetPage(
name: AppRoutes.onboarding,
page: () => const OnboardingScreen(),
binding: OnboardingControllerBinding(),
),
GetPage(
name: AppRoutes.chooseRole,
page: () => const ChooseRoleScreen(),
binding: ChooseRoleControllerBinding(),
),
// Auth
GetPage(
name: AppRoutes.signIn,
page: () => const SignInScreen(),
binding: SignInControllerBinding(),
),
GetPage(
name: AppRoutes.signUp,
page: () => const SignUpScreen(),
binding: SignUpControllerBinding(),
),
GetPage(
name: AppRoutes.forgotPassword,
page: () => const ForgotPasswordScreen(),
binding: ForgotPasswordControllerBinding(),
),
// Main pages
// GetPage(name: AppRoutes.explore, page: () => const ExploreScreen()),
// GetPage(name: AppRoutes.map, page: () => const MapScreen()),
// GetPage(name: AppRoutes.panicButton, page: () => const PanicButtonScreen()),
// GetPage(name: AppRoutes.communityWatch, page: () => const CommunityWatchScreen()),
// GetPage(name: AppRoutes.dailyOps, page: () => const DailyOpsScreen()),
// Personalization
// GetPage(name: AppRoutes.profile, page: () => const ProfileScreen()),
// GetPage(name: AppRoutes.settings, page: () => const SettingsScreen()),
];
}

View File

@ -0,0 +1,59 @@
import 'package:get/get.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
class SupabaseService extends GetxService {
static SupabaseService get instance => Get.find<SupabaseService>();
final _client = Supabase.instance.client;
/// Get Supabase client instance
SupabaseClient get client => _client;
/// Get current authenticated user
User? get currentUser => _client.auth.currentUser;
/// Get current user ID, if authenticated
String? get currentUserId => _client.auth.currentUser?.id;
/// Check if user is authenticated
bool get isAuthenticated => currentUser != null;
/// Check if session is expired
// bool get isSessionExpired => currentUser?.isExpired ?? true;
/// Check if current user is an officer based on metadata
bool get isOfficer {
final metadata = currentUser?.userMetadata;
if (metadata != null && metadata.containsKey('is_officer')) {
return metadata['is_officer'] == true;
}
return false;
}
/// Initialize Supabase service
Future<SupabaseService> init() async {
return this;
}
}
extension UserMetadataExtension on SupabaseService {
/// Update the current user's metadata
Future<void> updateUserMetadata(Map<String, dynamic> metadata) async {
if (!isAuthenticated) {
throw Exception('User is not authenticated');
}
try {
// Get existing metadata to merge with new values
final existingMetadata = currentUser?.userMetadata ?? {};
final mergedMetadata = {...existingMetadata, ...metadata};
// Update user metadata
await client.auth.updateUser(UserAttributes(data: mergedMetadata));
} on AuthException catch (e) {
throw Exception('Failed to update user metadata: ${e.message}');
} catch (e) {
throw Exception('An unexpected error occurred: $e');
}
}
}

View File

@ -0,0 +1,160 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/cores/repositories/auth/auth_repositories.dart';
import 'package:sigap/src/utils/constants/app_routes.dart';
import 'package:sigap/src/utils/popups/loaders.dart';
class EmailVerificationController extends GetxController {
// OTP text controllers
final List<TextEditingController> otpControllers = List.generate(
4,
(_) => TextEditingController(),
);
// Focus nodes for OTP fields
final List<FocusNode> focusNodes = List.generate(6, (_) => FocusNode());
// Observable variables
final RxBool isLoading = false.obs;
final RxBool isVerified = false.obs;
final RxBool isResendEnabled = false.obs;
final RxInt resendCountdown = 60.obs;
final RxString verificationError = ''.obs;
// Timer for resend countdown
Timer? _countdownTimer;
@override
void onInit() {
super.onInit();
startResendTimer();
}
@override
void onClose() {
_countdownTimer?.cancel();
for (var controller in otpControllers) {
controller.dispose();
}
for (var node in focusNodes) {
node.dispose();
}
super.onClose();
}
// Start the resend timer
void startResendTimer() {
resendCountdown.value = 60;
isResendEnabled.value = false;
_countdownTimer?.cancel();
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (resendCountdown.value > 0) {
resendCountdown.value--;
} else {
isResendEnabled.value = true;
timer.cancel();
}
});
}
// Handle OTP input change
void onOtpChanged(String value, int index) {
if (value.length == 1) {
// Move to next field
if (index < otpControllers.length - 1) {
focusNodes[index + 1].requestFocus();
} else {
// Last field filled, hide keyboard
focusNodes[index].unfocus();
// Verify OTP automatically when all fields are filled
verifyOtp();
}
}
}
// Get the complete OTP
String getOtp() {
return otpControllers.map((controller) => controller.text).join();
}
// Verify OTP
Future<void> verifyOtp() async {
final otp = getOtp();
// Check if OTP is complete
if (otp.length != otpControllers.length) {
verificationError.value = 'Please enter the complete verification code';
return;
}
try {
isLoading.value = true;
verificationError.value = '';
// Simulate API call
await Future.delayed(const Duration(seconds: 2));
// TODO: Implement actual OTP verification
// For demo, we'll consider "1234" as valid OTP
if (otp == "1234") {
isVerified.value = true;
// Navigate to role selection after successful verification
await Future.delayed(const Duration(seconds: 1));
Get.offNamed(AppRoutes.chooseRole);
} else {
verificationError.value =
'Invalid verification code. Please try again.';
}
} catch (e) {
verificationError.value = 'Verification failed: ${e.toString()}';
} finally {
isLoading.value = false;
}
}
// Resend verification code
Future<void> resendCode(String email) async {
if (!isResendEnabled.value) return;
try {
isLoading.value = true;
await AuthenticationRepository.instance.resendEmailVerification(email);
// Reset the timer
startResendTimer();
// Clear fields
for (var controller in otpControllers) {
controller.clear();
}
// Focus on first field
focusNodes[0].requestFocus();
// Show success message
TLoaders.successSnackBar(
title: 'Code Resent',
message: 'A new verification code has been sent to your email.',
);
} catch (e) {
verificationError.value = 'Resend failed: ${e.toString()}';
TLoaders.errorSnackBar(
title: 'Resend Failed',
message: 'Failed to resend the verification code. Please try again.',
);
} finally {
isLoading.value = false;
}
}
// Go back to sign in
void goToSignIn() {
Get.offAllNamed(AppRoutes.signIn);
}
}

View File

@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/utils/validators/validation.dart';
class ForgotPasswordController extends GetxController {
// Form key for validation
final formKey = GlobalKey<FormState>();
// Text controllers
final emailController = TextEditingController();
// Observable variables
final RxBool isLoading = false.obs;
final RxString emailError = ''.obs;
final RxBool isEmailSent = false.obs;
@override
void onClose() {
emailController.dispose();
super.onClose();
}
// Validate email
String? validateEmail(String? value) {
final error = TValidators.validateEmail(value);
emailError.value = error ?? '';
return error;
}
// Reset password method
Future<void> resetPassword() async {
// Clear previous errors
emailError.value = '';
// Validate form
if (formKey.currentState?.validate() != true) {
return;
}
try {
isLoading.value = true;
// Simulate API call
await Future.delayed(const Duration(seconds: 2));
// TODO: Implement actual password reset logic
// This would typically involve calling an authentication service
// Show success message
isEmailSent.value = true;
} catch (e) {
Get.snackbar(
'Error',
'Failed to send reset email: ${e.toString()}',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
} finally {
isLoading.value = false;
}
}
// Go back to sign in
void goBack() {
Get.back();
}
}

View File

@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:sigap/src/cores/repositories/auth/auth_repositories.dart';
import 'package:sigap/src/utils/constants/app_routes.dart';
import 'package:sigap/src/utils/helpers/network_manager.dart';
import 'package:sigap/src/utils/popups/loaders.dart';
class SignInController extends GetxController {
final rememberMe = false.obs;
final isPasswordVisible = false.obs;
final localStorage = GetStorage();
final email = TextEditingController();
final password = TextEditingController();
final emailError = ''.obs;
final passwordError = ''.obs;
final isLoading = false.obs;
GlobalKey<FormState> signinFormKey = GlobalKey<FormState>();
@override
void onInit() {
// Check if remember me is checked
if (localStorage.read('REMEMBER_ME_CHECK') == true) {
email.text = localStorage.read('REMEMBER_ME_EMAIL');
password.text = localStorage.read('REMEMBER_ME_PASSWORD');
}
super.onInit();
}
// Toggle password visibility
void togglePasswordVisibility() {
isPasswordVisible.value = !isPasswordVisible.value;
}
// Sign in method
Future<void> credentialsSignIn() async {
try {
// Start loading
// TFullScreenLoader.openLoadingDialog(
// 'Logging you in....',
// TImages.amongUsLoading,
// );
isLoading.value = true;
// Check connection
final isConected = await NetworkManager.instance.isConnected();
if (!isConected) {
// TFullScreenLoader.stopLoading();
isLoading.value = false;
return;
}
// Form validation
if (!signinFormKey.currentState!.validate()) {
// TFullScreenLoader.stopLoading();
emailError.value = '';
passwordError.value = '';
isLoading.value = false;
return;
}
// Store data if remember me is checked
if (rememberMe.value) {
localStorage.write('REMEMBER_ME_EMAIL', email.text);
localStorage.write('REMEMBER_ME_PASSWORD', password.text);
localStorage.write('REMEMBER_ME_CHECK', rememberMe.value);
}
// Login with credentials
await AuthenticationRepository.instance.signInWithEmailPassword(
email: email.text.trim(),
password: password.text.trim(),
);
// Stop loading
// TFullScreenLoader.stopLoading();
isLoading.value = false;
// Refresh user data
// final updatedUser = await UserRepository.instance.getUserDetailById();
// UserController.instance.user.value = updatedUser;
// Redirect to home screen
AuthenticationRepository.instance.screenRedirect();
} catch (e) {
// Remove loading
// TFullScreenLoader.stopLoading();
isLoading.value = false;
TLoaders.errorSnackBar(title: 'Oh snap', message: e.toString());
}
}
// -- Google Sign In Authentication
Future<void> googleSignIn() async {
try {
// Start loading
// TFullScreenLoader.openLoadingDialog(
// 'Logging you in....',
// TImages.amongUsLoading,
// );
isLoading.value = true;
// Check connection
final isConected = await NetworkManager.instance.isConnected();
if (!isConected) {
// TFullScreenLoader.stopLoading();
isLoading.value = false;
return;
}
// Form validation
if (!signinFormKey.currentState!.validate()) {
// TFullScreenLoader.stopLoading();
emailError.value = '';
passwordError.value = '';
isLoading.value = false;
return;
}
// Login with Google and save user data in Firebase Authtentication
await AuthenticationRepository.instance.signInWithGoogle();
// final role = localStorage.read('TEMP_ROLE');
// Logger().w(['User Data: $user']);
// Stop loading
// TFullScreenLoader.stopLoading();
isLoading.value = false;
// Redirect to home screen
AuthenticationRepository.instance.screenRedirect();
} catch (e) {
// Remove loading
// TFullScreenLoader.stopLoading();
isLoading.value = false;
TLoaders.errorSnackBar(title: 'Oh snap', message: e.toString());
}
}
// Navigate to sign up screen
void goToSignUp() {
Get.toNamed(AppRoutes.signUp);
}
// Navigate to forgot password screen
void goToForgotPassword() {
Get.toNamed(AppRoutes.forgotPassword);
}
}

View File

@ -0,0 +1,187 @@
import 'package:flutter/widgets.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:logger/logger.dart';
import 'package:sigap/src/cores/repositories/auth/auth_repositories.dart';
import 'package:sigap/src/utils/constants/image_strings.dart';
import 'package:sigap/src/utils/helpers/network_manager.dart';
import 'package:sigap/src/utils/popups/full_screen_loader.dart';
import 'package:sigap/src/utils/popups/loaders.dart';
class SignUpController extends GetxController {
static SignUpController get instance => Get.find();
// Variable
final storage = GetStorage();
final hidePassword = true.obs;
final hideConfirmPassword = true.obs;
final privacyPolicy = false.obs;
final isOfficer = false.obs; // Add flag for officer registration
final email = TextEditingController();
final firstName = TextEditingController();
final lastName = TextEditingController();
final username = TextEditingController();
final phoneNumber = TextEditingController();
final password = TextEditingController();
final confirmPassword = TextEditingController();
// Officer specific fields
final nrp = TextEditingController();
final rank = TextEditingController();
final position = TextEditingController();
final unitId =
TextEditingController(); // This should be selected from a dropdown
// State variables
final emailError = ''.obs;
final firstNameError = ''.obs;
final lastNameError = ''.obs;
final usernameError = ''.obs;
final phoneNumberError = ''.obs;
final passwordError = ''.obs;
final confirmPasswordError = ''.obs;
final isPasswordVisible = false.obs;
final isConfirmPasswordVisible = false.obs;
final isPrivacyPolicyAccepted = false.obs;
final isOfficerMode = false.obs;
final isOfficerError = ''.obs;
final isUnitIdError = ''.obs;
final isNrpError = ''.obs;
final isRankError = ''.obs;
final isPositionError = ''.obs;
final isLoading = false.obs;
GlobalKey<FormState> signupFormKey = GlobalKey<FormState>();
// Sign Up Function
void signUp() async {
Logger().i('SignUp process started');
try {
// Start loading
Logger().i('Opening loading dialog');
TFullScreenLoader.openLoadingDialog(
'Processing your information....',
TImages.amongUsLoading,
);
// Check connection
Logger().i('Checking network connection');
final isConnected = await NetworkManager.instance.isConnected();
if (!isConnected) {
Logger().w('No internet connection');
TFullScreenLoader.stopLoading();
TLoaders.errorSnackBar(
title: 'No Internet Connection',
message: 'Please check your internet connection and try again.',
);
return;
}
// Form validation
Logger().i('Validating form');
if (!signupFormKey.currentState!.validate()) {
Logger().w('Form validation failed');
TFullScreenLoader.stopLoading();
return;
}
// Check if user agreed to the terms and conditions
Logger().i('Checking privacy policy acceptance');
if (!privacyPolicy.value) {
Logger().w('Privacy policy not accepted');
TFullScreenLoader.stopLoading();
TLoaders.warningSnackBar(
title: 'Accept Privacy Policy',
message:
'In order to create account, you must have to read and accept the privacy policy & terms of use.',
);
return;
}
// Prepare user metadata based on user type (officer or viewer)
Map<String, dynamic> userMetadata = {
'first_name': firstName.text.trim(),
'last_name': lastName.text.trim(),
'phone': phoneNumber.text.trim(),
'is_officer': isOfficer.value,
};
// Add officer-specific data if registering as officer
if (isOfficer.value) {
if (unitId.text.isEmpty) {
Logger().w('Unit ID is required for officer registration');
TFullScreenLoader.stopLoading();
TLoaders.errorSnackBar(
title: 'Missing Information',
message: 'Unit ID is required for officer registration.',
);
return;
}
userMetadata['officer_data'] = {
'unit_id': unitId.text.trim(),
'nrp': nrp.text.trim(),
'name': "${firstName.text.trim()} ${lastName.text.trim()}",
'rank': rank.text.trim(),
'position': position.text.trim(),
'phone': phoneNumber.text.trim(),
};
}
// Register user with Supabase Auth
Logger().i('Registering user with Supabase Auth');
final authResponse = await AuthenticationRepository.instance
.signUpWithCredential(
email.text.trim(),
password.text.trim(),
userMetadata: userMetadata,
);
// Store email for verification screen
storage.write('CURRENT_USER_EMAIL', email.text.trim());
// Remove loading
Logger().i('Stopping loading dialog');
TFullScreenLoader.stopLoading();
// Show success message
Logger().i('Showing success message');
TLoaders.successSnackBar(
title: 'Congratulations',
message: 'Your account has been created! Verify email to continue.',
);
// Move to verification screen
Logger().i('Navigating to VerifyEmailScreen');
// Get.to(() => VerifyEmailScreen(email: email.text.trim()));
} catch (e) {
// Remove loading
Logger().e('Error occurred: $e');
TFullScreenLoader.stopLoading();
// Show error to the user
TLoaders.errorSnackBar(
title: 'Registration Failed',
message: e.toString(),
);
}
}
// Toggle officer registration mode
void toggleOfficerMode(bool value) {
isOfficer.value = value;
update();
}
// Navigate to sign in screen
void goToSignIn() {
Get.back();
}
}

View File

@ -0,0 +1,418 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/cores/services/supabase_service.dart';
import 'package:sigap/src/features/personalization/models/index.dart';
import 'package:sigap/src/utils/constants/app_routes.dart';
import 'package:sigap/src/utils/validators/validation.dart';
class StepFormController extends GetxController {
static StepFormController get to => Get.find();
// Role information
final Rx<RoleModel?> selectedRole = Rx<RoleModel?>(null);
// Current step index
final RxInt currentStep = 0.obs;
// Form keys for each step (dynamic based on role)
late List<GlobalKey<FormState>> stepFormKeys;
// Common information (for all roles)
final nameController = TextEditingController();
final phoneController = TextEditingController();
final addressController = TextEditingController();
// Viewer-specific fields
final emergencyNameController = TextEditingController();
final emergencyPhoneController = TextEditingController();
final relationshipController = TextEditingController();
// Officer-specific fields
final nrpController = TextEditingController();
final rankController = TextEditingController();
final positionController = TextEditingController();
final unitIdController = TextEditingController();
// Error states - Common
final RxString nameError = ''.obs;
final RxString phoneError = ''.obs;
final RxString addressError = ''.obs;
// Error states - Viewer
final RxString emergencyNameError = ''.obs;
final RxString emergencyPhoneError = ''.obs;
final RxString relationshipError = ''.obs;
// Error states - Officer
final RxString nrpError = ''.obs;
final RxString rankError = ''.obs;
final RxString positionError = ''.obs;
final RxString unitIdError = ''.obs;
// Loading state
final RxBool isLoading = false.obs;
// Available units for officer role
final RxList<Map<String, dynamic>> availableUnits =
<Map<String, dynamic>>[].obs;
@override
void onInit() {
super.onInit();
// Get role from arguments
final arguments = Get.arguments;
if (arguments != null && arguments['role'] != null) {
selectedRole.value = arguments['role'] as RoleModel;
_initializeBasedOnRole();
} else {
Get.snackbar(
'Error',
'No role selected. Please go back and select a role.',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
}
if (selectedRole.value?.isOfficer == true) {
_fetchAvailableUnits();
}
}
void _initializeBasedOnRole() {
if (selectedRole.value?.isOfficer == true) {
stepFormKeys = List.generate(3, (_) => GlobalKey<FormState>());
} else {
// Viewer role or default
stepFormKeys = List.generate(2, (_) => GlobalKey<FormState>());
}
}
Future<void> _fetchAvailableUnits() async {
try {
isLoading.value = true;
// Here we would fetch units from repository
// For now we'll use dummy data
await Future.delayed(const Duration(seconds: 1));
availableUnits.value = [
{"id": "unit1", "name": "Polres Jember"},
{"id": "unit2", "name": "Polsek Sumbersari"},
{"id": "unit3", "name": "Polsek Kaliwates"},
];
} catch (e) {
Get.snackbar(
'Error',
'Failed to load units: ${e.toString()}',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
} finally {
isLoading.value = false;
}
}
@override
void onClose() {
// Dispose all controllers
nameController.dispose();
phoneController.dispose();
addressController.dispose();
emergencyNameController.dispose();
emergencyPhoneController.dispose();
relationshipController.dispose();
nrpController.dispose();
rankController.dispose();
positionController.dispose();
unitIdController.dispose();
super.onClose();
}
// Validate current step
bool validateCurrentStep() {
clearErrors(); // Clear previous errors
final currentFormKey = stepFormKeys[currentStep.value];
if (currentFormKey.currentState?.validate() ?? false) {
return true;
}
// If validation failed using the form's built-in validation,
// we may want to perform additional validation and set error messages
if (currentStep.value == 0) {
validatePersonalInfo();
} else if (currentStep.value == 1) {
if (selectedRole.value?.isOfficer == true) {
validateOfficerInfo();
} else {
validateEmergencyContact();
}
} else if (currentStep.value == 2 &&
selectedRole.value?.isOfficer == true) {
validateOfficerAdditionalInfo();
}
return false;
}
void clearErrors() {
// Clear common errors
nameError.value = '';
phoneError.value = '';
addressError.value = '';
// Clear viewer-specific errors
emergencyNameError.value = '';
emergencyPhoneError.value = '';
relationshipError.value = '';
// Clear officer-specific errors
nrpError.value = '';
rankError.value = '';
positionError.value = '';
unitIdError.value = '';
}
bool validatePersonalInfo() {
bool isValid = true;
final nameValidation = TValidators.validateUserInput(
'Full name',
nameController.text,
100,
);
if (nameValidation != null) {
nameError.value = nameValidation;
isValid = false;
}
final phoneValidation = TValidators.validatePhoneNumber(
phoneController.text,
);
if (phoneValidation != null) {
phoneError.value = phoneValidation;
isValid = false;
}
final addressValidation = TValidators.validateUserInput(
'Address',
addressController.text,
255,
);
if (addressValidation != null) {
addressError.value = addressValidation;
isValid = false;
}
return isValid;
}
bool validateEmergencyContact() {
bool isValid = true;
final nameValidation = TValidators.validateUserInput(
'Emergency contact name',
emergencyNameController.text,
100,
);
if (nameValidation != null) {
emergencyNameError.value = nameValidation;
isValid = false;
}
final phoneValidation = TValidators.validatePhoneNumber(
emergencyPhoneController.text,
);
if (phoneValidation != null) {
emergencyPhoneError.value = phoneValidation;
isValid = false;
}
final relationshipValidation = TValidators.validateUserInput(
'Relationship',
relationshipController.text,
50,
);
if (relationshipValidation != null) {
relationshipError.value = relationshipValidation;
isValid = false;
}
return isValid;
}
bool validateOfficerInfo() {
bool isValid = true;
final nrpValidation = TValidators.validateUserInput(
'NRP',
nrpController.text,
50,
);
if (nrpValidation != null) {
nrpError.value = nrpValidation;
isValid = false;
}
final rankValidation = TValidators.validateUserInput(
'Rank',
rankController.text,
50,
);
if (rankValidation != null) {
rankError.value = rankValidation;
isValid = false;
}
return isValid;
}
bool validateOfficerAdditionalInfo() {
bool isValid = true;
final positionValidation = TValidators.validateUserInput(
'Position',
positionController.text,
100,
);
if (positionValidation != null) {
positionError.value = positionValidation;
isValid = false;
}
if (unitIdController.text.isEmpty) {
unitIdError.value = 'Please select a unit';
isValid = false;
}
return isValid;
}
// Go to next step
void nextStep() {
if (!validateCurrentStep()) return;
if (currentStep.value < stepFormKeys.length - 1) {
currentStep.value++;
} else {
submitForm();
}
}
// Go to previous step
void previousStep() {
if (currentStep.value > 0) {
currentStep.value--;
}
}
// Go to specific step
void goToStep(int step) {
if (step >= 0 && step < stepFormKeys.length) {
// Only allow going to a step if all previous steps are valid
bool canProceed = true;
for (int i = 0; i < step; i++) {
clearErrors(); // Clear errors before validating
final formKey = stepFormKeys[i];
if (!(formKey.currentState?.validate() ?? false)) {
canProceed = false;
currentStep.value = i;
break;
}
}
if (canProceed) {
currentStep.value = step;
}
}
}
// Submit the complete form
Future<void> submitForm() async {
// Validate all steps
bool isValid = true;
for (int i = 0; i < stepFormKeys.length; i++) {
final formKey = stepFormKeys[i];
if (!(formKey.currentState?.validate() ?? false)) {
isValid = false;
currentStep.value = i;
break;
}
}
if (!isValid) return;
try {
isLoading.value = true;
// Prepare data based on role
final Map<String, dynamic> userData = {
'name': nameController.text,
'phone': phoneController.text,
'address': addressController.text,
};
if (selectedRole.value?.isOfficer == true) {
// Officer role
final officerData = {
'nrp': nrpController.text,
'rank': rankController.text,
'position': positionController.text,
'unit_id': unitIdController.text,
};
// Update auth user with officer flag and data
await SupabaseService.instance.updateUserMetadata({
'is_officer': true,
'officer_data': {
...officerData,
'name': userData['name'],
'phone': userData['phone'],
},
});
} else {
// Viewer role
final emergencyContact = {
'name': emergencyNameController.text,
'phone': emergencyPhoneController.text,
'relationship': relationshipController.text,
};
// Update auth user with viewer role
await SupabaseService.instance.updateUserMetadata({
'is_officer': false,
'emergency_contact': emergencyContact,
'address': userData['address'],
});
}
// Navigate to success screen
Get.toNamed(
AppRoutes.stateScreen,
arguments: {
'type': 'success',
'title': 'Profile Completed',
'message': 'Your profile information has been successfully saved.',
'buttonText': 'Continue',
'onButtonPressed': () => Get.offAllNamed(AppRoutes.explore),
},
);
} catch (e) {
Get.toNamed(
AppRoutes.stateScreen,
arguments: {
'type': 'error',
'title': 'Submission Failed',
'message': 'There was an error saving your profile: ${e.toString()}',
'buttonText': 'Try Again',
'onButtonPressed': () => Get.back(),
},
);
} finally {
isLoading.value = false;
}
}
}

View File

@ -0,0 +1,179 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/auth/controllers/email_verification_controller.dart';
import 'package:sigap/src/features/auth/screens/widgets/auth_button.dart';
import 'package:sigap/src/features/auth/screens/widgets/auth_header.dart';
import 'package:sigap/src/features/auth/screens/widgets/otp_input_field.dart';
import 'package:sigap/src/utils/constants/colors.dart';
class EmailVerificationScreen extends StatelessWidget {
const EmailVerificationScreen({super.key});
@override
Widget build(BuildContext context) {
// Get the controller
final controller = Get.find<EmailVerificationController>();
// Set system overlay style
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
),
);
return Scaffold(
backgroundColor: TColors.light,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: TColors.textPrimary),
onPressed: controller.goToSignIn,
),
),
body: SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Obx(
() =>
controller.isVerified.value
? _buildVerifiedView()
: _buildVerificationForm(controller),
),
),
),
),
);
}
Widget _buildVerificationForm(EmailVerificationController controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Header
const AuthHeader(
title: 'Email Verification',
subtitle: 'Enter the 4-digit code sent to your email',
),
const SizedBox(height: 32),
// OTP input fields
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(
4,
(index) => OtpInputField(
controller: controller.otpControllers[index],
focusNode: controller.focusNodes[index],
autoFocus: index == 0,
onChanged: (value) => controller.onOtpChanged(value, index),
),
),
),
const SizedBox(height: 16),
// Error message
Obx(
() =>
controller.verificationError.value.isNotEmpty
? Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
controller.verificationError.value,
style: TextStyle(color: TColors.error, fontSize: 14),
),
)
: const SizedBox.shrink(),
),
const SizedBox(height: 32),
// Verify button
Obx(
() => AuthButton(
text: 'Verify',
onPressed: controller.verifyOtp,
isLoading: controller.isLoading.value,
),
),
const SizedBox(height: 24),
// Resend code
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Didn\'t receive the code?',
style: TextStyle(color: TColors.textSecondary),
),
Obx(
() => TextButton(
onPressed:
controller.isResendEnabled.value
? controller.resendCode
: null,
child: Text(
controller.isResendEnabled.value
? 'Resend'
: 'Resend in ${controller.resendCountdown.value}s',
style: TextStyle(
color:
controller.isResendEnabled.value
? TColors.primary
: TColors.textSecondary,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
],
);
}
Widget _buildVerifiedView() {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 32),
// Success icon
Icon(Icons.check_circle_outline, size: 80, color: TColors.success),
const SizedBox(height: 24),
// Success message
Text(
'Email Verified',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: TColors.textPrimary,
),
),
const SizedBox(height: 16),
Text(
'Your email has been successfully verified. Please wait while we redirect you.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, color: TColors.textSecondary),
),
const SizedBox(height: 32),
// Loading indicator
CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(TColors.primary),
),
],
);
}
}

View File

@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/auth/controllers/forgot_password_controller.dart';
import 'package:sigap/src/features/auth/screens/widgets/auth_button.dart';
import 'package:sigap/src/features/auth/screens/widgets/auth_header.dart';
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
import 'package:sigap/src/utils/constants/colors.dart';
class ForgotPasswordScreen extends StatelessWidget {
const ForgotPasswordScreen({super.key});
@override
Widget build(BuildContext context) {
// Get the controller
final controller = Get.find<ForgotPasswordController>();
// Set system overlay style
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
),
);
return Scaffold(
backgroundColor: TColors.light,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: TColors.textPrimary),
onPressed: controller.goBack,
),
),
body: SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Obx(
() => Form(
key: controller.formKey,
child:
controller.isEmailSent.value
? _buildSuccessView(controller)
: _buildFormView(controller),
),
),
),
),
),
);
}
Widget _buildFormView(ForgotPasswordController controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
const AuthHeader(
title: 'Forgot Password',
subtitle: 'Enter your email to reset your password',
),
// Email field
Obx(
() => CustomTextField(
label: 'Email',
controller: controller.emailController,
validator: controller.validateEmail,
keyboardType: TextInputType.emailAddress,
errorText: controller.emailError.value,
),
),
const SizedBox(height: 24),
// Reset password button
Obx(
() => AuthButton(
text: 'Reset Password',
onPressed: controller.resetPassword,
isLoading: controller.isLoading.value,
),
),
],
);
}
Widget _buildSuccessView(ForgotPasswordController controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 32),
// Success icon
Icon(Icons.check_circle_outline, size: 80, color: TColors.success),
const SizedBox(height: 24),
// Success message
Text(
'Email Sent',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: TColors.textPrimary,
),
),
const SizedBox(height: 16),
Text(
'We have sent a password recovery instructions to your email.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, color: TColors.textSecondary),
),
const SizedBox(height: 32),
// Back to sign in button
AuthButton(text: 'Back to Sign In', onPressed: controller.goBack),
],
);
}
}

View File

@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/auth/controllers/signin_controller.dart';
import 'package:sigap/src/features/auth/screens/widgets/auth_button.dart';
import 'package:sigap/src/features/auth/screens/widgets/auth_divider.dart';
import 'package:sigap/src/features/auth/screens/widgets/auth_header.dart';
import 'package:sigap/src/features/auth/screens/widgets/password_field.dart';
import 'package:sigap/src/features/auth/screens/widgets/social_button.dart';
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
import 'package:sigap/src/utils/constants/colors.dart';
import 'package:sigap/src/utils/validators/validation.dart';
class SignInScreen extends StatelessWidget {
const SignInScreen({super.key});
@override
Widget build(BuildContext context) {
// Get the controller
final controller = Get.find<SignInController>();
// Set system overlay style
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
),
);
return Scaffold(
backgroundColor: TColors.light,
body: SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Form(
key: controller.signinFormKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
// Header
const AuthHeader(
title: 'Welcome Back',
subtitle: 'Sign in to your account to continue',
),
// Email field
Obx(
() => CustomTextField(
label: 'Email',
controller: controller.email,
validator: TValidators.validateEmail,
keyboardType: TextInputType.emailAddress,
errorText: controller.emailError.value,
textInputAction: TextInputAction.next,
),
),
// Password field
Obx(
() => PasswordField(
label: 'Password',
controller: controller.password,
validator: TValidators.validatePassword,
isVisible: controller.isPasswordVisible,
errorText: controller.passwordError.value,
onToggleVisibility: controller.togglePasswordVisibility,
),
),
// Forgot password
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: controller.goToForgotPassword,
child: Text(
'Forgot Password?',
style: TextStyle(
color: TColors.primary,
fontWeight: FontWeight.w500,
),
),
),
),
const SizedBox(height: 16),
// Sign in button
Obx(
() => AuthButton(
text: 'Sign In',
onPressed: controller.credentialsSignIn,
isLoading: controller.isLoading.value,
),
),
const SizedBox(height: 24),
// Or divider
const AuthDivider(text: 'OR'),
const SizedBox(height: 24),
// Social sign in buttons
SocialButton(
text: 'Continue with Google',
icon: Icons.g_mobiledata,
onPressed: () => controller.googleSignIn(),
),
const SizedBox(height: 16),
// Don't have an account
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Don\'t have an account?',
style: TextStyle(color: TColors.textSecondary),
),
TextButton(
onPressed: controller.goToSignUp,
child: Text(
'Sign Up',
style: TextStyle(
color: TColors.primary,
fontWeight: FontWeight.w500,
),
),
),
],
),
],
),
),
),
),
),
);
}
}

View File

@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/auth/controllers/signup_controller.dart';
import 'package:sigap/src/features/auth/screens/widgets/auth_button.dart';
import 'package:sigap/src/features/auth/screens/widgets/auth_divider.dart';
import 'package:sigap/src/features/auth/screens/widgets/auth_header.dart';
import 'package:sigap/src/features/auth/screens/widgets/password_field.dart';
import 'package:sigap/src/features/auth/screens/widgets/social_button.dart';
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
import 'package:sigap/src/utils/constants/colors.dart';
class SignUpScreen extends StatelessWidget {
const SignUpScreen({super.key});
@override
Widget build(BuildContext context) {
// Get the controller
final controller = Get.find<SignUpController>();
// Set system overlay style
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
),
);
return Scaffold(
backgroundColor: TColors.light,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: TColors.textPrimary),
onPressed: () => Get.back(),
),
),
body: SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Form(
key: controller.formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
const AuthHeader(
title: 'Create Account',
subtitle: 'Sign up to get started with the app',
),
// Name field
Obx(
() => CustomTextField(
label: 'Full Name',
controller: controller.nameController,
validator: controller.validateName,
errorText: controller.nameError.value,
textInputAction: TextInputAction.next,
),
),
// Email field
Obx(
() => CustomTextField(
label: 'Email',
controller: controller.emailController,
validator: controller.validateEmail,
keyboardType: TextInputType.emailAddress,
errorText: controller.emailError.value,
textInputAction: TextInputAction.next,
),
),
// Password field
Obx(
() => PasswordField(
label: 'Password',
controller: controller.passwordController,
validator: controller.validatePassword,
isVisible: controller.isPasswordVisible,
errorText: controller.passwordError.value,
onToggleVisibility: controller.togglePasswordVisibility,
textInputAction: TextInputAction.next,
),
),
// Confirm password field
Obx(
() => PasswordField(
label: 'Confirm Password',
controller: controller.confirmPasswordController,
validator: controller.validateConfirmPassword,
isVisible: controller.isConfirmPasswordVisible,
errorText: controller.confirmPasswordError.value,
onToggleVisibility:
controller.toggleConfirmPasswordVisibility,
),
),
const SizedBox(height: 24),
// Sign up button
Obx(
() => AuthButton(
text: 'Sign Up',
onPressed: controller.signUp,
isLoading: controller.isLoading.value,
),
),
const SizedBox(height: 24),
// Or divider
const AuthDivider(text: 'OR'),
const SizedBox(height: 24),
// Social sign up button
SocialButton(
text: 'Continue with Google',
icon: Icons.g_mobiledata,
onPressed: () {
// TODO: Implement Google sign up
},
),
const SizedBox(height: 16),
// Already have an account
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Already have an account?',
style: TextStyle(color: TColors.textSecondary),
),
TextButton(
onPressed: controller.goToSignIn,
child: Text(
'Sign In',
style: TextStyle(
color: TColors.primary,
fontWeight: FontWeight.w500,
),
),
),
],
),
],
),
),
),
),
),
);
}
}

View File

@ -0,0 +1,402 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/auth/controllers/step_form_controller.dart';
import 'package:sigap/src/features/auth/screens/widgets/auth_button.dart';
import 'package:sigap/src/features/personalization/models/index.dart';
import 'package:sigap/src/shared/widgets/dropdown/custom_dropdown.dart';
import 'package:sigap/src/shared/widgets/indicators/step_indicator.dart';
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
import 'package:sigap/src/utils/constants/colors.dart';
import 'package:sigap/src/utils/validators/validation.dart';
class StepFormScreen extends StatelessWidget {
const StepFormScreen({super.key});
@override
Widget build(BuildContext context) {
// Get the controller
final controller = Get.find<StepFormController>();
// Set system overlay style
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
),
);
return Scaffold(
backgroundColor: TColors.light,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
title: Obx(
() => Text(
'Complete Your ${controller.selectedRole.value?.name ?? ""} Profile',
style: TextStyle(
color: TColors.textPrimary,
fontWeight: FontWeight.bold,
),
),
),
centerTitle: true,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: TColors.textPrimary),
onPressed: () => Get.back(),
),
),
body: Obx(() {
// Show loading while initializing
if (controller.selectedRole.value == null) {
return const Center(child: CircularProgressIndicator());
}
return SafeArea(
child: Column(
children: [
// Step indicator
Padding(
padding: const EdgeInsets.all(24.0),
child: Obx(
() => StepIndicator(
currentStep: controller.currentStep.value,
totalSteps: controller.stepFormKeys.length,
stepTitles: _getStepTitles(controller.selectedRole.value!),
onStepTapped: controller.goToStep,
),
),
),
// Step content
Expanded(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Obx(() {
return _buildStepContent(controller);
}),
),
),
),
// Navigation buttons
Padding(
padding: const EdgeInsets.all(24.0),
child: Row(
children: [
// Back button
Obx(
() =>
controller.currentStep.value > 0
? Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: AuthButton(
text: 'Previous',
onPressed: controller.previousStep,
isPrimary: false,
),
),
)
: const SizedBox.shrink(),
),
// Next/Submit button
Expanded(
child: Padding(
padding: EdgeInsets.only(
left: controller.currentStep.value > 0 ? 8.0 : 0.0,
),
child: Obx(
() => AuthButton(
text:
controller.currentStep.value ==
controller.stepFormKeys.length - 1
? 'Submit'
: 'Next',
onPressed: controller.nextStep,
isLoading: controller.isLoading.value,
),
),
),
),
],
),
),
],
),
);
}),
);
}
List<String> _getStepTitles(RoleModel role) {
if (role.isOfficer) {
return ['Personal', 'Officer Info', 'Unit Info'];
} else {
return ['Personal', 'Emergency'];
}
}
Widget _buildStepContent(StepFormController controller) {
final isOfficer = controller.selectedRole.value?.isOfficer ?? false;
switch (controller.currentStep.value) {
case 0:
return _buildPersonalInfoStep(controller);
case 1:
return isOfficer
? _buildOfficerInfoStep(controller)
: _buildEmergencyContactStep(controller);
case 2:
// This step only exists for officers
if (isOfficer) {
return _buildOfficerAdditionalInfoStep(controller);
}
return const SizedBox.shrink();
default:
return const SizedBox.shrink();
}
}
Widget _buildPersonalInfoStep(StepFormController controller) {
return Form(
key: controller.stepFormKeys[0],
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Personal Information',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: TColors.textPrimary,
),
),
const SizedBox(height: 8),
Text(
'Please provide your personal details',
style: TextStyle(fontSize: 14, color: TColors.textSecondary),
),
const SizedBox(height: 24),
// Name field
Obx(
() => CustomTextField(
label: 'Full Name',
controller: controller.nameController,
validator:
(value) =>
TValidators.validateUserInput('Full name', value, 100),
errorText: controller.nameError.value,
textInputAction: TextInputAction.next,
),
),
// Phone field
Obx(
() => CustomTextField(
label: 'Phone Number',
controller: controller.phoneController,
validator: TValidators.validatePhoneNumber,
errorText: controller.phoneError.value,
keyboardType: TextInputType.phone,
textInputAction: TextInputAction.next,
),
),
// Address field
Obx(
() => CustomTextField(
label: 'Address',
controller: controller.addressController,
validator:
(value) =>
TValidators.validateUserInput('Address', value, 255),
errorText: controller.addressError.value,
textInputAction: TextInputAction.done,
maxLines: 3,
),
),
],
),
);
}
Widget _buildEmergencyContactStep(StepFormController controller) {
return Form(
key: controller.stepFormKeys[1],
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Emergency Contact',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: TColors.textPrimary,
),
),
const SizedBox(height: 8),
Text(
'Please provide emergency contact details',
style: TextStyle(fontSize: 14, color: TColors.textSecondary),
),
const SizedBox(height: 24),
// Emergency contact name field
Obx(
() => CustomTextField(
label: 'Contact Name',
controller: controller.emergencyNameController,
validator:
(value) => TValidators.validateUserInput(
'Emergency contact name',
value,
100,
),
errorText: controller.emergencyNameError.value,
textInputAction: TextInputAction.next,
),
),
// Emergency contact phone field
Obx(
() => CustomTextField(
label: 'Contact Phone',
controller: controller.emergencyPhoneController,
validator: TValidators.validatePhoneNumber,
errorText: controller.emergencyPhoneError.value,
keyboardType: TextInputType.phone,
textInputAction: TextInputAction.next,
),
),
// Relationship field
Obx(
() => CustomTextField(
label: 'Relationship',
controller: controller.relationshipController,
validator:
(value) =>
TValidators.validateUserInput('Relationship', value, 50),
errorText: controller.relationshipError.value,
textInputAction: TextInputAction.done,
),
),
],
),
);
}
Widget _buildOfficerInfoStep(StepFormController controller) {
return Form(
key: controller.stepFormKeys[1],
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Officer Information',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: TColors.textPrimary,
),
),
const SizedBox(height: 8),
Text(
'Please provide your officer details',
style: TextStyle(fontSize: 14, color: TColors.textSecondary),
),
const SizedBox(height: 24),
// NRP field
Obx(
() => CustomTextField(
label: 'NRP',
controller: controller.nrpController,
validator: TValidators.validateNRP,
errorText: controller.nrpError.value,
textInputAction: TextInputAction.next,
),
),
// Rank field
Obx(
() => CustomTextField(
label: 'Rank',
controller: controller.rankController,
validator: TValidators.validateRank,
errorText: controller.rankError.value,
textInputAction: TextInputAction.done,
),
),
],
),
);
}
Widget _buildOfficerAdditionalInfoStep(StepFormController controller) {
return Form(
key: controller.stepFormKeys[2],
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Unit Information',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: TColors.textPrimary,
),
),
const SizedBox(height: 8),
Text(
'Please provide your unit details',
style: TextStyle(fontSize: 14, color: TColors.textSecondary),
),
const SizedBox(height: 24),
// Position field
Obx(
() => CustomTextField(
label: 'Position',
controller: controller.positionController,
validator: TValidators.validatePosition,
errorText: controller.positionError.value,
textInputAction: TextInputAction.next,
),
),
// Unit Dropdown
Obx(
() => CustomDropdown(
label: 'Unit',
items:
controller.availableUnits
.map(
(unit) => DropdownMenuItem(
value: unit['id'],
child: Text(unit['name']),
),
)
.toList(),
value:
controller.unitIdController.text.isEmpty
? null
: controller.unitIdController.text,
onChanged: (value) {
if (value != null) {
controller.unitIdController.text = value.toString();
}
},
validator: TValidators.validateUnitId,
errorText: controller.unitIdError.value,
),
),
],
),
);
}
}

View File

@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:sigap/src/utils/constants/colors.dart';
class AuthButton extends StatelessWidget {
final String text;
final VoidCallback onPressed;
final bool isLoading;
final bool isPrimary;
const AuthButton({
super.key,
required this.text,
required this.onPressed,
this.isLoading = false,
this.isPrimary = true,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
height: 56,
child:
isPrimary
? ElevatedButton(
onPressed: isLoading ? null : onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: TColors.primary,
foregroundColor: TColors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 0,
disabledBackgroundColor: TColors.primary.withOpacity(0.6),
),
child:
isLoading
? SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
TColors.white,
),
),
)
: Text(
text,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
)
: OutlinedButton(
onPressed: isLoading ? null : onPressed,
style: OutlinedButton.styleFrom(
foregroundColor: TColors.primary,
side: BorderSide(color: TColors.primary),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
disabledForegroundColor: TColors.primary.withOpacity(0.6),
),
child:
isLoading
? SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
TColors.primary,
),
),
)
: Text(
text,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
);
}
}

View File

@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:sigap/src/utils/constants/colors.dart';
class AuthDivider extends StatelessWidget {
final String text;
const AuthDivider({super.key, required this.text});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(child: Divider(color: TColors.borderPrimary, thickness: 1)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
text,
style: TextStyle(color: TColors.textSecondary, fontSize: 14),
),
),
Expanded(child: Divider(color: TColors.borderPrimary, thickness: 1)),
],
);
}
}

View File

@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:sigap/src/utils/constants/colors.dart';
class AuthHeader extends StatelessWidget {
final String title;
final String subtitle;
const AuthHeader({super.key, required this.title, required this.subtitle});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: TColors.textPrimary,
),
),
const SizedBox(height: 8),
Text(
subtitle,
style: TextStyle(fontSize: 16, color: TColors.textSecondary),
),
const SizedBox(height: 32),
],
);
}
}

View File

@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sigap/src/utils/constants/colors.dart';
class OtpInputField extends StatelessWidget {
final TextEditingController controller;
final FocusNode focusNode;
final Function(String) onChanged;
final bool autoFocus;
const OtpInputField({
super.key,
required this.controller,
required this.focusNode,
required this.onChanged,
this.autoFocus = false,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 60,
height: 60,
child: TextFormField(
controller: controller,
focusNode: focusNode,
autofocus: autoFocus,
onChanged: onChanged,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
inputFormatters: [
LengthLimitingTextInputFormatter(1),
FilteringTextInputFormatter.digitsOnly,
],
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: TColors.textPrimary,
),
decoration: InputDecoration(
contentPadding: EdgeInsets.zero,
filled: true,
fillColor: TColors.lightContainer,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: TColors.borderPrimary, width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: TColors.borderPrimary, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: TColors.primary, width: 2),
),
),
),
);
}
}

View File

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/utils/constants/colors.dart';
import '../../../../shared/widgets/text/custom_text_field.dart';
class PasswordField extends StatelessWidget {
final String label;
final TextEditingController controller;
final String? Function(String?)? validator;
final RxBool isVisible;
final String? errorText;
final TextInputAction textInputAction;
final Function()? onToggleVisibility;
const PasswordField({
super.key,
required this.label,
required this.controller,
this.validator,
required this.isVisible,
this.errorText,
this.textInputAction = TextInputAction.done,
this.onToggleVisibility,
});
@override
Widget build(BuildContext context) {
return Obx(
() => CustomTextField(
label: label,
controller: controller,
validator: validator,
obscureText: !isVisible.value,
errorText: errorText,
textInputAction: textInputAction,
suffixIcon: IconButton(
icon: Icon(
isVisible.value ? Icons.visibility_off : Icons.visibility,
color: TColors.textSecondary,
),
onPressed: onToggleVisibility,
),
),
);
}
}

View File

@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:sigap/src/utils/constants/colors.dart';
class SocialButton extends StatelessWidget {
final String text;
final IconData icon;
final VoidCallback onPressed;
const SocialButton({
super.key,
required this.text,
required this.icon,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
height: 56,
child: OutlinedButton.icon(
onPressed: onPressed,
icon: Icon(icon, color: TColors.textPrimary),
label: Text(
text,
style: TextStyle(
color: TColors.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
style: OutlinedButton.styleFrom(
foregroundColor: TColors.textPrimary,
side: BorderSide(color: TColors.borderPrimary),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
);
}
}

View File

@ -0,0 +1,96 @@
import 'package:get/get.dart';
import 'package:sigap/src/cores/repositories/roles/roles_repository.dart';
import 'package:sigap/src/features/personalization/models/index.dart';
import 'package:sigap/src/utils/constants/app_routes.dart';
import 'package:sigap/src/utils/popups/loaders.dart';
class ChooseRoleController extends GetxController {
static ChooseRoleController get instance => Get.find();
// Repositories
final _rolesRepository = Get.find<RolesRepository>();
// List of available roles
final RxList<RoleModel> roles = <RoleModel>[].obs;
// Selected role
final Rx<RoleModel?> selectedRole = Rx<RoleModel?>(null);
// Loading state
final RxBool isLoading = false.obs;
final RxBool isOfficer = false.obs;
@override
void onInit() {
super.onInit();
fetchRoles();
}
// Fetch available roles from repository
Future<void> fetchRoles() async {
try {
isLoading.value = true;
// Get all roles from repository
final allRoles = await _rolesRepository.getAllRoles();
// Filter roles to only include viewer and officer
roles.value =
allRoles
.where(
(role) =>
role.name.toLowerCase() == 'viewer' ||
role.name.toLowerCase() == 'officer',
)
.toList();
} catch (e) {
TLoaders.errorSnackBar(
title: 'Error',
message: 'Failed to load available roles. Please try again.',
);
} finally {
isLoading.value = false;
}
}
// Select a role
void selectRole(RoleModel role) {
selectedRole.value = role;
}
// Continue with selected role
Future<void> continueWithRole() async {
if (selectedRole.value == null) {
TLoaders.errorSnackBar(
title: 'Error',
message: 'Please select a role to continue.',
);
return;
}
try {
isLoading.value = true;
// Check if the selected role is officer
if (selectedRole.value!.name.toLowerCase() == 'officer') {
isOfficer.value = true;
} else {
isOfficer.value = false;
}
// Navigate to sign up screen with selected role
Get.offNamed(
AppRoutes.signUp,
arguments: {'role': selectedRole.value, 'isOfficer': isOfficer.value},
);
} catch (e) {
TLoaders.errorSnackBar(
title: 'Error',
message:
'An error occurred while selecting the role. Please try again.',
);
} finally {
isLoading.value = false;
}
}
}

View File

@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/onboarding/datas/onboarding_data.dart';
import 'package:sigap/src/utils/constants/app_routes.dart';
class OnboardingController extends GetxController
with GetSingleTickerProviderStateMixin {
// Singleton instance
static OnboardingController get instance => Get.find();
// Observable variables
final RxInt currentIndex = 0.obs;
final PageController pageController = PageController(initialPage: 0);
// Animation controllers
late AnimationController animationController;
late Animation<double> fadeAnimation;
late Animation<Offset> slideAnimation;
@override
void onInit() {
super.onInit();
// Initialize animation controllers
animationController = AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
);
fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: animationController, curve: Curves.easeIn),
);
slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.2),
end: Offset.zero,
).animate(
CurvedAnimation(parent: animationController, curve: Curves.easeOut),
);
animationController.forward();
}
@override
void onClose() {
pageController.dispose();
animationController.dispose();
super.onClose();
}
// Method to handle page changes
void onPageChanged(int index) {
currentIndex.value = index;
animationController.reset();
animationController.forward();
}
// Method to go to next page
void nextPage() {
if (currentIndex.value == contents.length - 1) {
navigateToWelcomeScreen();
} else {
pageController.nextPage(
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
);
}
}
// Method to skip to welcome screen
void skipToWelcomeScreen() {
navigateToWelcomeScreen();
}
// Method to navigate to welcome screen
void navigateToWelcomeScreen() {
Get.offAllNamed(AppRoutes.welcome);
}
void getStarted() {
Get.offAllNamed(AppRoutes.chooseRole);
}
void goToSignIn() {
Get.offAllNamed(AppRoutes.signIn);
}
void goToSignUp() {
Get.offAllNamed(AppRoutes.signUp);
}
}

View File

@ -0,0 +1,22 @@
import 'package:sigap/src/features/onboarding/models/onboarding_model.dart';
final List<OnboardingContent> contents = [
OnboardingContent(
title: 'Crime Mapping & Analysis',
description:
'Visualize crime levels across Jember Regency with advanced K-means clustering based on population density, unemployment, and crime incidents.',
image: 'assets/images/onboarding1.png',
),
OnboardingContent(
title: 'Emergency Panic Button',
description:
'One-click emergency alert with automatic geolocation transmission to connect directly with police dispatch in critical situations.',
image: 'assets/images/onboarding2.png',
),
OnboardingContent(
title: 'Data-Driven Insights',
description:
'Track crime trends from 2020 to 2024 with interactive maps and comprehensive reports for better decision making.',
image: 'assets/images/onboarding3.png',
),
];

View File

@ -0,0 +1,12 @@
class OnboardingContent {
final String title;
final String description;
final String image;
OnboardingContent({
required this.title,
required this.description,
required this.image,
});
}

View File

@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/auth/screens/widgets/auth_button.dart';
import 'package:sigap/src/features/auth/screens/widgets/auth_header.dart';
import 'package:sigap/src/features/onboarding/controllers/choose_role_controller.dart';
import 'package:sigap/src/features/onboarding/screens/choose-role/widgets/role_card.dart';
import 'package:sigap/src/utils/constants/colors.dart';
class ChooseRoleScreen extends StatelessWidget {
const ChooseRoleScreen({super.key});
@override
Widget build(BuildContext context) {
// Get the controller
final controller = Get.find<ChooseRoleController>();
// Set system overlay style
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
),
);
return Scaffold(
backgroundColor: TColors.light,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
title: Text(
'Choose Role',
style: TextStyle(
color: TColors.textPrimary,
fontWeight: FontWeight.bold,
),
),
centerTitle: true,
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
const AuthHeader(
title: 'Select Your Role',
subtitle: 'Choose the role that best describes your position',
),
// Role list
Expanded(
child: Obx(
() => ListView.builder(
itemCount: controller.roles.length,
itemBuilder: (context, index) {
final role = controller.roles[index];
return Obx(
() => RoleCard(
role: role,
isSelected:
controller.selectedRole.value?.id == role.id,
onTap: () => controller.selectRole(role),
),
);
},
),
),
),
// Continue button
Obx(
() => AuthButton(
text: 'Continue',
onPressed: controller.continueWithRole,
isLoading: controller.isLoading.value,
),
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:sigap/src/features/personalization/models/index.dart';
import 'package:sigap/src/utils/constants/colors.dart';
class RoleCard extends StatelessWidget {
final RoleModel role;
final bool isSelected;
final VoidCallback onTap;
const RoleCard({
super.key,
required this.role,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color:
isSelected
? TColors.primary.withOpacity(0.1)
: TColors.lightContainer,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isSelected ? TColors.primary : TColors.borderPrimary,
width: isSelected ? 2 : 1,
),
),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: isSelected ? TColors.primary : TColors.secondary,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
role.iconData,
color: isSelected ? TColors.white : TColors.primary,
size: 28,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
role.name,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: TColors.textPrimary,
),
),
const SizedBox(height: 4),
Text(
role.description!,
style: TextStyle(
fontSize: 14,
color: TColors.textSecondary,
),
),
],
),
),
Icon(
isSelected ? Icons.check_circle : Icons.circle_outlined,
color: isSelected ? TColors.primary : TColors.textSecondary,
size: 24,
),
],
),
),
);
}
}

View File

@ -0,0 +1,198 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/onboarding/datas/onboarding_data.dart';
import 'package:sigap/src/utils/constants/colors.dart';
import '../../controllers/onboarding_controller.dart';
class OnboardingScreen extends StatelessWidget {
const OnboardingScreen({super.key});
@override
Widget build(BuildContext context) {
// Get the controller
final controller = Get.find<OnboardingController>();
// Set system overlay style
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
),
);
// Get screen dimensions for responsive design
final size = MediaQuery.of(context).size;
final isSmallScreen = size.height < 700;
return Scaffold(
backgroundColor: TColors.light,
body: SafeArea(
child: Column(
children: [
// Skip button
Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: TextButton(
onPressed: controller.skipToWelcomeScreen,
child: Text(
'Skip',
style: TextStyle(
color: TColors.textSecondary,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
),
),
// Page view for slides
Expanded(
flex: 3,
child: PageView.builder(
controller: controller.pageController,
itemCount: contents.length,
onPageChanged: controller.onPageChanged,
itemBuilder: (_, i) {
return Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
children: [
// Animated image
Expanded(
flex: isSmallScreen ? 3 : 4,
child: FadeTransition(
opacity: controller.fadeAnimation,
child: SlideTransition(
position: controller.slideAnimation,
child: Image.asset(
contents[i].image,
fit: BoxFit.contain,
),
),
),
),
SizedBox(height: isSmallScreen ? 16 : 32),
// Animated title
FadeTransition(
opacity: controller.fadeAnimation,
child: SlideTransition(
position: controller.slideAnimation,
child: Text(
contents[i].title,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: isSmallScreen ? 22 : 28,
fontWeight: FontWeight.bold,
color: TColors.textPrimary,
),
),
),
),
SizedBox(height: isSmallScreen ? 12 : 20),
// Animated description
FadeTransition(
opacity: controller.fadeAnimation,
child: SlideTransition(
position: controller.slideAnimation,
child: Text(
contents[i].description,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: isSmallScreen ? 14 : 16,
color: TColors.textSecondary,
height: 1.5,
),
),
),
),
],
),
);
},
),
),
// Indicator and buttons
Expanded(
flex: 1,
child: Column(
children: [
// Dot indicators
Obx(
() => Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
contents.length,
(index) =>
buildDot(index, controller.currentIndex.value),
),
),
),
const Spacer(),
// Next or Get Started button
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 24.0,
vertical: 16.0,
),
child: SizedBox(
width: double.infinity,
height: 56,
child: Obx(
() => ElevatedButton(
onPressed: controller.nextPage,
style: ElevatedButton.styleFrom(
backgroundColor: TColors.primary,
foregroundColor: TColors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 0,
),
child: Text(
controller.currentIndex.value == contents.length - 1
? 'Get Started'
: 'Next',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
),
),
],
),
),
],
),
),
);
}
// Animated dot indicator
Widget buildDot(int index, int currentIndex) {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
margin: const EdgeInsets.only(right: 8),
height: 8,
width: currentIndex == index ? 24 : 8,
decoration: BoxDecoration(
color: currentIndex == index ? TColors.primary : TColors.grey,
borderRadius: BorderRadius.circular(4),
),
);
}
}

View File

@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/onboarding/controllers/onboarding_controller.dart';
import 'package:sigap/src/utils/constants/colors.dart';
class WelcomeScreen extends StatelessWidget {
const WelcomeScreen({super.key});
@override
Widget build(BuildContext context) {
final controller = Get.find<OnboardingController>();
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
),
);
final size = MediaQuery.of(context).size;
final isSmallScreen = size.height < 700;
return Scaffold(
backgroundColor: TColors.light,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 32),
Hero(
tag: 'app_logo',
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: TColors.primary.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
Icons.location_on,
size: 50,
color: TColors.primary,
),
),
),
SizedBox(height: isSmallScreen ? 24 : 40),
Text(
'Welcome to GIS Crime Cluster',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: isSmallScreen ? 24 : 28,
fontWeight: FontWeight.bold,
color: TColors.textPrimary,
),
),
SizedBox(height: isSmallScreen ? 12 : 16),
Text(
'Visualize crime patterns and enhance safety across Jember Regency with our data-driven GIS application',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: isSmallScreen ? 14 : 16,
color: TColors.textSecondary,
height: 1.5,
),
),
const Spacer(),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: controller.getStarted,
style: ElevatedButton.styleFrom(
backgroundColor: TColors.primary,
foregroundColor: TColors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 0,
),
child: const Text(
'Get Started',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
),
SizedBox(height: isSmallScreen ? 24 : 32),
Text(
'By continuing, you agree to our Terms of Service and Privacy Policy',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12, color: TColors.textSecondary),
),
const SizedBox(height: 16),
],
),
),
),
);
}
}

View File

@ -0,0 +1,4 @@
export 'users_model.dart';
export 'officers_model.dart';
export 'profile_model.dart';
export 'roles_model.dart';

View File

@ -0,0 +1,159 @@
import 'package:sigap/src/features/personalization/models/roles_model.dart';
class OfficerModel {
final String id;
final String unitId;
final String roleId;
final String? patrolUnitId;
final String nrp;
final String name;
final String? rank;
final String? position;
final String? phone;
final String? email;
final String? avatar;
final DateTime? validUntil;
final String? qrCode;
final DateTime? createdAt;
final DateTime? updatedAt;
final RoleModel? role;
OfficerModel({
required this.id,
required this.unitId,
required this.roleId,
this.patrolUnitId,
required this.nrp,
required this.name,
this.rank,
this.position,
this.phone,
this.email,
this.avatar,
this.validUntil,
this.qrCode,
this.createdAt,
this.updatedAt,
this.role,
});
// Create an OfficerModel instance from a JSON object
factory OfficerModel.fromJson(Map<String, dynamic> json) {
return OfficerModel(
id: json['id'] as String,
unitId: json['unit_id'] as String,
roleId: json['role_id'] as String,
patrolUnitId: json['patrol_unit_id'] as String?,
nrp: json['nrp'] as String,
name: json['name'] as String,
rank: json['rank'] as String?,
position: json['position'] as String?,
phone: json['phone'] as String?,
email: json['email'] as String?,
avatar: json['avatar'] as String?,
validUntil:
json['valid_until'] != null
? DateTime.parse(json['valid_until'] as String)
: null,
qrCode: json['qr_code'] as String?,
createdAt:
json['created_at'] != null
? DateTime.parse(json['created_at'] as String)
: null,
updatedAt:
json['updated_at'] != null
? DateTime.parse(json['updated_at'] as String)
: null,
role:
json['roles'] != null
? RoleModel.fromJson(json['roles'] as Map<String, dynamic>)
: null,
);
}
// Convert an OfficerModel instance to a JSON object
Map<String, dynamic> toJson() {
return {
'id': id,
'unit_id': unitId,
'role_id': roleId,
'patrol_unit_id': patrolUnitId,
'nrp': nrp,
'name': name,
'rank': rank,
'position': position,
'phone': phone,
'email': email,
'avatar': avatar,
'valid_until': validUntil?.toIso8601String(),
'qr_code': qrCode,
'created_at': createdAt?.toIso8601String(),
'updated_at': updatedAt?.toIso8601String(),
if (role != null) 'roles': role!.toJson(),
};
}
// Create a copy of the OfficerModel with updated fields
OfficerModel copyWith({
String? id,
String? unitId,
String? roleId,
String? patrolUnitId,
String? nrp,
String? name,
String? rank,
String? position,
String? phone,
String? email,
String? avatar,
DateTime? validUntil,
String? qrCode,
DateTime? createdAt,
DateTime? updatedAt,
RoleModel? role,
}) {
return OfficerModel(
id: id ?? this.id,
unitId: unitId ?? this.unitId,
roleId: roleId ?? this.roleId,
patrolUnitId: patrolUnitId ?? this.patrolUnitId,
nrp: nrp ?? this.nrp,
name: name ?? this.name,
rank: rank ?? this.rank,
position: position ?? this.position,
phone: phone ?? this.phone,
email: email ?? this.email,
avatar: avatar ?? this.avatar,
validUntil: validUntil ?? this.validUntil,
qrCode: qrCode ?? this.qrCode,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
role: role ?? this.role,
);
}
// Create an OfficerModel from a User metadata
factory OfficerModel.fromUserMetadata(
String userId,
Map<String, dynamic> metadata,
) {
final officerData = metadata['officer_data'] ?? {};
return OfficerModel(
id: userId,
unitId: officerData['unit_id'] ?? '',
roleId: '', // This would need to be fetched or defined elsewhere
nrp: officerData['nrp'] ?? '',
name: officerData['name'] ?? '',
rank: officerData['rank'],
position: officerData['position'],
phone: officerData['phone'],
email: metadata['email'],
);
}
@override
String toString() {
return 'OfficerModel(id: $id, name: $name, nrp: $nrp)';
}
}

View File

@ -0,0 +1,95 @@
class ProfileModel {
final String id;
final String userId;
final String? avatar;
final String? username;
final String? firstName;
final String? lastName;
final String? bio;
final Map<String, dynamic>? address;
final DateTime? birthDate;
ProfileModel({
required this.id,
required this.userId,
this.avatar,
this.username,
this.firstName,
this.lastName,
this.bio,
this.address,
this.birthDate,
});
// Create a ProfileModel instance from a JSON object
factory ProfileModel.fromJson(Map<String, dynamic> json) {
return ProfileModel(
id: json['id'] as String,
userId: json['user_id'] as String,
avatar: json['avatar'] as String?,
username: json['username'] as String?,
firstName: json['first_name'] as String?,
lastName: json['last_name'] as String?,
bio: json['bio'] as String?,
address:
json['address'] != null
? Map<String, dynamic>.from(json['address'] as Map)
: null,
birthDate:
json['birth_date'] != null
? DateTime.parse(json['birth_date'] as String)
: null,
);
}
// Convert a ProfileModel instance to a JSON object
Map<String, dynamic> toJson() {
return {
'id': id,
'user_id': userId,
'avatar': avatar,
'username': username,
'first_name': firstName,
'last_name': lastName,
'bio': bio,
'address': address,
'birth_date': birthDate?.toIso8601String(),
};
}
// Create a copy of the ProfileModel with updated fields
ProfileModel copyWith({
String? id,
String? userId,
String? avatar,
String? username,
String? firstName,
String? lastName,
String? bio,
Map<String, dynamic>? address,
DateTime? birthDate,
}) {
return ProfileModel(
id: id ?? this.id,
userId: userId ?? this.userId,
avatar: avatar ?? this.avatar,
username: username ?? this.username,
firstName: firstName ?? this.firstName,
lastName: lastName ?? this.lastName,
bio: bio ?? this.bio,
address: address ?? this.address,
birthDate: birthDate ?? this.birthDate,
);
}
// Get full name from first name and last name
String? get fullName {
if (firstName == null && lastName == null) return null;
return [firstName, lastName].where((x) => x != null).join(' ');
}
@override
String toString() {
return 'ProfileModel(id: $id, username: $username, fullName: $fullName)';
}
}

View File

@ -0,0 +1,103 @@
import 'package:flutter/cupertino.dart';
import 'package:iconsax/iconsax.dart';
class RoleModel {
final String id;
final String name;
final String? icon;
final String? description;
final DateTime createdAt;
final DateTime updatedAt;
RoleModel({
required this.id,
required this.name,
this.icon,
this.description,
required this.createdAt,
required this.updatedAt,
});
// Create a RoleModel instance from a JSON object
factory RoleModel.fromJson(Map<String, dynamic> json) {
return RoleModel(
id: json['id'] as String,
name: json['name'] as String,
icon: json['icon'] as String?,
description: json['description'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
}
// Convert a RoleModel instance to a JSON object
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'description': description,
'icon': icon,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
};
}
// Create a copy of the RoleModel with updated fields
RoleModel copyWith({
String? id,
String? name,
String? icon,
String? description,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return RoleModel(
id: id ?? this.id,
name: name ?? this.name,
icon: icon ?? this.icon,
description: description ?? this.description,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
// Check if the role is an officer role
bool get isOfficer => name.toLowerCase() == 'officer';
// Check if the role is a viewer role
bool get isViewer => name.toLowerCase() == 'viewer';
// Convert the instance method to a static method for easier access
static IconData iconRole(String? iconName) {
if (iconName != null) {
// Map icon string to IconData
switch (iconName) {
case 'user':
return Iconsax.user;
case 'eye':
return CupertinoIcons.eye;
// Add more mappings as needed
default:
return Iconsax.user; // fallback
}
}
return Iconsax.user; // default icon
}
// Keep the instance getter for convenience
IconData get iconData {
if (icon != null) {
return RoleModel.iconRole(icon);
} else if (isOfficer) {
return Iconsax.user;
} else if (isViewer) {
return CupertinoIcons.person;
}
return Iconsax.user; // default icon
}
@override
String toString() {
return 'RoleModel(id: $id, name: $name)';
}
}

View File

@ -0,0 +1,172 @@
import 'package:sigap/src/features/personalization/models/profile_model.dart';
import 'package:sigap/src/features/personalization/models/roles_model.dart';
class UserModel {
final String id;
final String rolesId;
final String email;
final String? phone;
final String? encryptedPassword;
final DateTime? invitedAt;
final DateTime? confirmedAt;
final DateTime? emailConfirmedAt;
final DateTime? recoverySentAt;
final DateTime? lastSignInAt;
final Map<String, dynamic>? appMetadata;
final Map<String, dynamic>? userMetadata;
final DateTime createdAt;
final DateTime updatedAt;
final DateTime? bannedUntil;
final bool isAnonymous;
final ProfileModel? profile;
final RoleModel? role;
UserModel({
required this.id,
required this.rolesId,
required this.email,
this.phone,
this.encryptedPassword,
this.invitedAt,
this.confirmedAt,
this.emailConfirmedAt,
this.recoverySentAt,
this.lastSignInAt,
this.appMetadata,
this.userMetadata,
required this.createdAt,
required this.updatedAt,
this.bannedUntil,
required this.isAnonymous,
this.profile,
this.role,
});
// Create a UserModel instance from a JSON object
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
id: json['id'] as String,
rolesId: json['roles_id'] as String,
email: json['email'] as String,
phone: json['phone'] as String?,
encryptedPassword: json['encrypted_password'] as String?,
invitedAt:
json['invited_at'] != null
? DateTime.parse(json['invited_at'] as String)
: null,
confirmedAt:
json['confirmed_at'] != null
? DateTime.parse(json['confirmed_at'] as String)
: null,
emailConfirmedAt:
json['email_confirmed_at'] != null
? DateTime.parse(json['email_confirmed_at'] as String)
: null,
recoverySentAt:
json['recovery_sent_at'] != null
? DateTime.parse(json['recovery_sent_at'] as String)
: null,
lastSignInAt:
json['last_sign_in_at'] != null
? DateTime.parse(json['last_sign_in_at'] as String)
: null,
appMetadata: json['app_metadata'] as Map<String, dynamic>?,
userMetadata: json['user_metadata'] as Map<String, dynamic>?,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
bannedUntil:
json['banned_until'] != null
? DateTime.parse(json['banned_until'] as String)
: null,
isAnonymous: json['is_anonymous'] as bool? ?? false,
profile:
json['profiles'] != null
? ProfileModel.fromJson(json['profiles'] as Map<String, dynamic>)
: null,
role:
json['role'] != null
? RoleModel.fromJson(json['role'] as Map<String, dynamic>)
: null,
);
}
// Convert a UserModel instance to a JSON object
Map<String, dynamic> toJson() {
return {
'id': id,
'roles_id': rolesId,
'email': email,
'phone': phone,
'encrypted_password': encryptedPassword,
'invited_at': invitedAt?.toIso8601String(),
'confirmed_at': confirmedAt?.toIso8601String(),
'email_confirmed_at': emailConfirmedAt?.toIso8601String(),
'recovery_sent_at': recoverySentAt?.toIso8601String(),
'last_sign_in_at': lastSignInAt?.toIso8601String(),
'app_metadata': appMetadata,
'user_metadata': userMetadata,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'banned_until': bannedUntil?.toIso8601String(),
'is_anonymous': isAnonymous,
if (profile != null) 'profiles': profile!.toJson(),
if (role != null) 'role': role!.toJson(),
};
}
// Create a copy of the UserModel with updated fields
UserModel copyWith({
String? id,
String? rolesId,
String? email,
String? phone,
String? encryptedPassword,
DateTime? invitedAt,
DateTime? confirmedAt,
DateTime? emailConfirmedAt,
DateTime? recoverySentAt,
DateTime? lastSignInAt,
Map<String, dynamic>? appMetadata,
Map<String, dynamic>? userMetadata,
DateTime? createdAt,
DateTime? updatedAt,
DateTime? bannedUntil,
bool? isAnonymous,
ProfileModel? profile,
RoleModel? role,
}) {
return UserModel(
id: id ?? this.id,
rolesId: rolesId ?? this.rolesId,
email: email ?? this.email,
phone: phone ?? this.phone,
encryptedPassword: encryptedPassword ?? this.encryptedPassword,
invitedAt: invitedAt ?? this.invitedAt,
confirmedAt: confirmedAt ?? this.confirmedAt,
emailConfirmedAt: emailConfirmedAt ?? this.emailConfirmedAt,
recoverySentAt: recoverySentAt ?? this.recoverySentAt,
lastSignInAt: lastSignInAt ?? this.lastSignInAt,
appMetadata: appMetadata ?? this.appMetadata,
userMetadata: userMetadata ?? this.userMetadata,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
bannedUntil: bannedUntil ?? this.bannedUntil,
isAnonymous: isAnonymous ?? this.isAnonymous,
profile: profile ?? this.profile,
role: role ?? this.role,
);
}
// Check if user is an officer based on metadata
bool get isOfficer {
if (userMetadata != null && userMetadata!.containsKey('is_officer')) {
return userMetadata!['is_officer'] == true;
}
return false;
}
@override
String toString() {
return 'UserModel(id: $id, email: $email)';
}
}

View File

@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:iconsax/iconsax.dart';
import 'package:sigap/src/utils/constants/colors.dart';
import 'package:sigap/src/utils/constants/sizes.dart';
class CommonController extends GetxController {
static CommonController get instance => Get.find<CommonController>();
void showConfirmationDialog({
required String title,
required String message,
required VoidCallback onConfirm,
}) {
Get.defaultDialog(
title: title,
titleStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
middleText: message,
middleTextStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.normal,
),
backgroundColor: TColors.white,
radius: 16,
actions: [
TextButton(onPressed: () => Get.back(), child: const Text('Cancel')),
TextButton(
onPressed: () {
onConfirm();
Get.back();
},
child: const Text('Confirm'),
),
],
);
}
void showDeleteDialog(BuildContext context) {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
alignment: Alignment.center,
backgroundColor: TColors.white,
title: Text(
'Delete post from denta koas app ?',
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),
content: Text(
'Post cannot be recovered once deleted. Are you sure you want to delete this post?',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
actions: [
SizedBox(
width: double.infinity,
child: TextButton(
onPressed: () {
// Handle delete action
Navigator.of(context).pop();
},
style: TextButton.styleFrom(
overlayColor: TColors.darkGrey,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(TSizes.cardRadiusSm),
),
),
child: Text(
'Delete',
style: Theme.of(
context,
).textTheme.bodySmall!.apply(color: TColors.error),
),
),
),
SizedBox(
width: double.infinity,
child: TextButton(
onPressed: () {
Navigator.of(context).pop();
},
style: TextButton.styleFrom(
overlayColor: TColors.darkGrey,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(TSizes.cardRadiusSm),
),
),
child: Text(
'Cancel',
style: Theme.of(context).textTheme.bodySmall,
),
),
),
],
);
},
);
}
void showOptions(BuildContext context) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
builder: (BuildContext context) {
return Wrap(
children: [
ListTile(
leading: const Icon(Iconsax.edit),
title: const Text('Edit'),
onTap: () {
Get.back();
},
),
ListTile(
leading: const Icon(Iconsax.close_circle),
title: const Text('Close'),
onTap: () {
Get.back();
},
),
ListTile(
leading: const Icon(Iconsax.trash, color: TColors.error),
title: const Text(
'Delete',
style: TextStyle(color: TColors.error),
),
onTap: () {
Get.back();
showDeleteDialog(context);
},
),
],
);
},
);
}
void likeButton() {
var isLiked = false.obs;
void toggleLike() {
isLiked.value = !isLiked.value;
}
Obx(
() => IconButton(
icon: Icon(
Iconsax.heart,
color: isLiked.value ? TColors.error : TColors.grey,
),
onPressed: toggleLike,
),
);
}
}

View File

@ -0,0 +1,11 @@
import 'package:flutter/material.dart';
import 'package:sigap/src/utils/constants/sizes.dart';
class DSpacingStyle {
static const EdgeInsetsGeometry paddingWithAppBarHeight = EdgeInsets.only(
top: TSizes.appBarHeight,
left: TSizes.defaultSpace,
right: TSizes.defaultSpace,
bottom: TSizes.defaultSpace,
);
}

View File

@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:iconsax/iconsax.dart';
import 'package:sigap/src/utils/constants/sizes.dart';
import 'package:sigap/src/utils/device/device_utility.dart';
class DAppBar extends StatelessWidget implements PreferredSizeWidget {
final Widget? avatar;
final Widget? title;
final IconData? leadingIcon;
final List<Widget>? actions;
final VoidCallback? leadingOnPressed;
final VoidCallback? onBack;
final bool showBackArrow;
final bool centerTitle;
final bool showActions;
const DAppBar({
super.key,
this.avatar,
this.title,
this.leadingIcon,
this.actions,
this.leadingOnPressed,
this.showBackArrow = false,
this.centerTitle = false,
this.onBack,
this.showActions = false,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: TSizes.defaultSpace),
child: AppBar(
centerTitle: centerTitle,
automaticallyImplyLeading: false,
leading:
showBackArrow
? IconButton(
onPressed: onBack ?? () => Get.back(),
icon: const Icon(Icons.chevron_left),
)
: leadingIcon != null
? IconButton(
onPressed: leadingOnPressed,
icon: Icon(leadingIcon),
)
: null,
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (avatar != null) ...[
CircleAvatar(
radius: 24,
backgroundColor: Colors.transparent,
child: avatar,
),
const SizedBox(width: 8), // Spasi antara avatar dan title
],
if (title != null) title!, // Title jika ada
],
),
actions:
showActions
? actions ?? []
: [
IconButton(
onPressed: () {},
icon: const Icon(Iconsax.notification),
),
IconButton(
onPressed: () {},
icon: const Icon(Iconsax.search_normal),
),
],
),
);
}
@override
Size get preferredSize => Size.fromHeight(TDeviceUtils.getAppBarHeight());
}

View File

@ -0,0 +1,109 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:sigap/src/utils/constants/colors.dart';
import 'package:sigap/src/utils/device/device_utility.dart';
import 'package:sigap/src/utils/helpers/helper_functions.dart';
class TabBarApp extends StatelessWidget implements PreferredSizeWidget {
const TabBarApp({
super.key,
required this.tabs,
this.controller,
this.isScrollable = false,
this.padding,
this.indicatorColor = TColors.primary,
this.automaticIndicatorColorAdjustment = true,
this.indicatorWeight = 2.0,
this.indicatorPadding = EdgeInsets.zero,
this.indicator,
this.indicatorSize,
this.dividerColor,
this.dividerHeight,
this.labelColor = TColors.primary,
this.labelStyle,
this.labelPadding,
this.unselectedLabelColor = TColors.darkGrey,
this.unselectedLabelStyle,
this.dragStartBehavior = DragStartBehavior.start,
this.overlayColor,
this.mouseCursor,
this.enableFeedback,
this.onTap,
this.physics,
this.splashFactory,
this.splashBorderRadius,
this.tabAlignment = TabAlignment.fill,
this.textScaler,
this.indicatorAnimation,
});
final List<Widget> tabs;
final TabController? controller;
final bool isScrollable;
final EdgeInsetsGeometry? padding;
final Color? indicatorColor;
final bool automaticIndicatorColorAdjustment;
final double indicatorWeight;
final EdgeInsetsGeometry indicatorPadding;
final Decoration? indicator;
final TabBarIndicatorSize? indicatorSize;
final Color? dividerColor;
final double? dividerHeight;
final Color? labelColor;
final TextStyle? labelStyle;
final EdgeInsetsGeometry? labelPadding;
final Color? unselectedLabelColor;
final TextStyle? unselectedLabelStyle;
final DragStartBehavior dragStartBehavior;
final WidgetStateProperty<Color?>? overlayColor;
final MouseCursor? mouseCursor;
final bool? enableFeedback;
final void Function(int)? onTap;
final ScrollPhysics? physics;
final InteractiveInkFeatureFactory? splashFactory;
final BorderRadius? splashBorderRadius;
final TabAlignment? tabAlignment;
final TextScaler? textScaler;
final TabIndicatorAnimation? indicatorAnimation;
@override
Widget build(BuildContext context) {
final dark = THelperFunctions.isDarkMode(context);
return Material(
color: dark ? TColors.black : TColors.white,
child: TabBar(
tabs: tabs,
controller: controller,
isScrollable: isScrollable,
padding: padding,
indicatorColor: indicatorColor,
automaticIndicatorColorAdjustment: automaticIndicatorColorAdjustment,
indicatorWeight: indicatorWeight,
indicatorPadding: indicatorPadding,
indicator: indicator,
indicatorSize: indicatorSize,
dividerColor: dividerColor,
dividerHeight: dividerHeight,
labelColor: labelColor,
labelStyle: labelStyle,
labelPadding: labelPadding,
unselectedLabelColor: unselectedLabelColor,
unselectedLabelStyle: unselectedLabelStyle,
dragStartBehavior: dragStartBehavior,
mouseCursor: mouseCursor,
enableFeedback: enableFeedback,
onTap: onTap,
physics: physics,
splashFactory: splashFactory,
tabAlignment: tabAlignment,
textScaler: textScaler,
indicatorAnimation: indicatorAnimation,
overlayColor: WidgetStateProperty.all(Colors.blue.shade50),
splashBorderRadius: splashBorderRadius,
),
);
}
@override
Size get preferredSize => Size.fromHeight(TDeviceUtils.getAppBarHeight());
}

View File

@ -0,0 +1,43 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:sigap/src/utils/constants/colors.dart';
import 'package:sigap/src/utils/popups/loaders.dart';
class LikeButton extends StatefulWidget {
final String postId;
const LikeButton({super.key, required this.postId});
@override
LikeButtonState createState() => LikeButtonState();
}
class LikeButtonState extends State<LikeButton> {
bool isLiked = false; // Status awal tombol like
void toggleLike() async {
bool newIsLiked = !isLiked; // Ubah status like sementara
try {
// Panggil API untuk like atau unlike
// Jika berhasil, baru ubah status like
setState(() {
isLiked = newIsLiked;
});
} catch (e) {
TLoaders.errorSnackBar(title: 'Error', message: e.toString());
}
}
@override
Widget build(BuildContext context) {
return IconButton(
icon: Icon(
isLiked ? CupertinoIcons.heart_fill : CupertinoIcons.heart,
color: isLiked ? TColors.primary : Colors.grey,
),
onPressed: toggleLike,
);
}
}

Some files were not shown because too many files have changed in this diff Show More