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
|
@ -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
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"denoland.vscode-deno"
|
||||||
|
]
|
||||||
|
}
|
|
@ -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"
|
||||||
|
}
|
|
@ -7,6 +7,9 @@
|
||||||
|
|
||||||
# The following line activates a set of recommended lints for Flutter apps,
|
# The following line activates a set of recommended lints for Flutter apps,
|
||||||
# packages, and plugins designed to encourage good coding practices.
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
|
analyzer:
|
||||||
|
errors:
|
||||||
|
unused_element: ignore
|
||||||
include: package:flutter_lints/flutter.yaml
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
|
|
|
@ -1,35 +1,60 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<application
|
<!-- Required to fetch data from the internet. -->
|
||||||
android:label="sigap"
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
android:name="${applicationName}"
|
<!-- ... -->
|
||||||
android:icon="@mipmap/ic_launcher">
|
<application android:label="sigap"
|
||||||
<activity
|
android:name="${applicationName}"
|
||||||
android:name=".MainActivity"
|
android:icon="@mipmap/ic_launcher">
|
||||||
android:exported="true"
|
<activity android:name=".MainActivity"
|
||||||
android:launchMode="singleTop"
|
android:exported="true"
|
||||||
android:taskAffinity=""
|
android:launchMode="singleTop"
|
||||||
android:theme="@style/LaunchTheme"
|
android:taskAffinity=""
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:theme="@style/LaunchTheme"
|
||||||
android:hardwareAccelerated="true"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:hardwareAccelerated="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
the Android process has started. This theme is visible to the user
|
the Android process has started. This theme is visible to the user
|
||||||
while the Flutter UI initializes. After that, this theme continues
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
to determine the Window background behind the Flutter UI. -->
|
to determine the Window background behind the Flutter UI. -->
|
||||||
<meta-data
|
<meta-data android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
android:name="io.flutter.embedding.android.NormalTheme"
|
android:resource="@style/NormalTheme" />
|
||||||
android:resource="@style/NormalTheme"
|
|
||||||
/>
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<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>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data android:name="flutterEmbedding"
|
||||||
android:name="flutterEmbedding"
|
android:value="2" />
|
||||||
android:value="2" />
|
|
||||||
</application>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
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. -->
|
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||||
<queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
<action android:name="android.intent.action.PROCESS_TEXT" />
|
||||||
<data android:mimeType="text/plain"/>
|
<data android:mimeType="text/plain" />
|
||||||
</intent>
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
</manifest>
|
</manifest>
|
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 16 KiB |
After Width: | Height: | Size: 17 KiB |
After Width: | Height: | Size: 22 KiB |
After Width: | Height: | Size: 1.9 MiB |
After Width: | Height: | Size: 1.1 MiB |
After Width: | Height: | Size: 8.6 KiB |
After Width: | Height: | Size: 1.2 MiB |
After Width: | Height: | Size: 1.3 MiB |
After Width: | Height: | Size: 2.0 MiB |
After Width: | Height: | Size: 2.7 MiB |
After Width: | Height: | Size: 12 MiB |
After Width: | Height: | Size: 369 KiB |
After Width: | Height: | Size: 247 KiB |
After Width: | Height: | Size: 6.5 KiB |
After Width: | Height: | Size: 2.5 KiB |
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 8.4 KiB |
|
@ -1,49 +1,62 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Sigap</string>
|
<string>Sigap</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>sigap</string>
|
<string>sigap</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true />
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UIMainStoryboardFile</key>
|
<key>UIMainStoryboardFile</key>
|
||||||
<string>Main</string>
|
<string>Main</string>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true />
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true />
|
||||||
</dict>
|
<!-- 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>
|
</plist>
|
|
@ -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)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,122 +1,39 @@
|
||||||
import 'package:flutter/material.dart';
|
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() {
|
Future<void> main() async {
|
||||||
runApp(const MyApp());
|
// Ensure that the Flutter binding is initialized before calling any Flutter
|
||||||
}
|
final WidgetsBinding widgetBinding =
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
class MyApp extends StatelessWidget {
|
|
||||||
const MyApp({super.key});
|
// -- GetX Local Storage
|
||||||
|
await GetStorage.init();
|
||||||
// This widget is the root of your application.
|
|
||||||
@override
|
// -- Await splash until other item load
|
||||||
Widget build(BuildContext context) {
|
FlutterNativeSplash.preserve(widgetsBinding: widgetBinding);
|
||||||
return MaterialApp(
|
|
||||||
title: 'Flutter Demo',
|
// Initialize the authentication repository with Supabase
|
||||||
theme: ThemeData(
|
await Supabase.initialize(
|
||||||
// This is the theme of your application.
|
url: dotenv.env['SUPABASE_URL']!,
|
||||||
//
|
anonKey: dotenv.env['SUPABASE_ANON_KEY']!,
|
||||||
// TRY THIS: Try running your application with "flutter run". You'll see
|
authOptions: const FlutterAuthClientOptions(
|
||||||
// the application has a purple toolbar. Then, without quitting the app,
|
authFlowType: AuthFlowType.pkce,
|
||||||
// try changing the seedColor in the colorScheme below to Colors.green
|
// detectSessionInUri: true,
|
||||||
// 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
|
realtimeClientOptions: RealtimeClientOptions(
|
||||||
// the command line to start the app).
|
logLevel: RealtimeLogLevel.info,
|
||||||
//
|
),
|
||||||
// Notice that the counter didn't reset back to zero; the application
|
storageOptions: const StorageClientOptions(retryAttempts: 10),
|
||||||
// state is not lost during the reload. To reset the state, use hot
|
);
|
||||||
// restart instead.
|
|
||||||
//
|
// Add this to your dependencies initialization:
|
||||||
// This works for code too, not just values: Most code changes can be
|
await Get.putAsync(() => SupabaseService().init());
|
||||||
// tested with just a hot reload.
|
|
||||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
runApp(const App());
|
||||||
),
|
|
||||||
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.
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()),
|
||||||
|
];
|
||||||
|
}
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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',
|
||||||
|
),
|
||||||
|
];
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
export 'users_model.dart';
|
||||||
|
export 'officers_model.dart';
|
||||||
|
export 'profile_model.dart';
|
||||||
|
export 'roles_model.dart';
|
|
@ -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)';
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)';
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)';
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)';
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
|
@ -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());
|
||||||
|
}
|
|
@ -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());
|
||||||
|
}
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|