From a498687e152f580f91b49fd63ffa55856737c014 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Mon, 10 Feb 2025 00:52:06 +0700 Subject: [PATCH 001/104] feat: envirotment preparation --- lib/app/app.dart | 17 +++ lib/app/middleware/auth_middleware.dart | 15 +++ lib/app/routes/app_pages.dart | 25 ++++ lib/app/routes/app_routes.dart | 8 ++ lib/core/utils/logger.dart | 22 ++++ lib/feature/home/presentation/home_page.dart | 14 ++ .../login/presentation/login_page.dart | 14 ++ .../presentation/splash_screen_page.dart | 26 ++++ lib/main.dart | 123 +----------------- pubspec.lock | 16 +++ pubspec.yaml | 2 + test/widget_test.dart | 48 +++---- 12 files changed, 185 insertions(+), 145 deletions(-) create mode 100644 lib/app/app.dart create mode 100644 lib/app/middleware/auth_middleware.dart create mode 100644 lib/app/routes/app_pages.dart create mode 100644 lib/app/routes/app_routes.dart create mode 100644 lib/core/utils/logger.dart create mode 100644 lib/feature/home/presentation/home_page.dart create mode 100644 lib/feature/login/presentation/login_page.dart create mode 100644 lib/feature/splash_screen/presentation/splash_screen_page.dart diff --git a/lib/app/app.dart b/lib/app/app.dart new file mode 100644 index 0000000..ca51c17 --- /dev/null +++ b/lib/app/app.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; +import 'package:get/get_navigation/src/root/get_material_app.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return GetMaterialApp( + debugShowCheckedModeBanner: false, + title: 'Quiz App', + initialRoute: AppRoutes.splashScreen, + getPages: AppPages.routes, + ); + } +} diff --git a/lib/app/middleware/auth_middleware.dart b/lib/app/middleware/auth_middleware.dart new file mode 100644 index 0000000..ee77cf4 --- /dev/null +++ b/lib/app/middleware/auth_middleware.dart @@ -0,0 +1,15 @@ +import 'package:flutter/src/widgets/navigator.dart'; +import 'package:get/get_navigation/src/routes/route_middleware.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/core/utils/logger.dart'; + +class AuthMiddleware extends GetMiddleware { + @override + RouteSettings? redirect(String? route) { + String name = "home"; + if (route != null && !route.contains(name)) return RouteSettings(name: AppRoutes.loginPage); + + logC.i("its not contain"); + return null; + } +} diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart new file mode 100644 index 0000000..88c40c8 --- /dev/null +++ b/lib/app/routes/app_pages.dart @@ -0,0 +1,25 @@ +import 'package:get/get_navigation/src/routes/get_route.dart'; +import 'package:quiz_app/app/middleware/auth_middleware.dart'; +import 'package:quiz_app/feature/home/presentation/home_page.dart'; +import 'package:quiz_app/feature/login/presentation/login_page.dart'; +import 'package:quiz_app/feature/splash_screen/presentation/splash_screen_page.dart'; + +part 'app_routes.dart'; + +class AppPages { + static List> routes = [ + GetPage( + name: AppRoutes.splashScreen, + page: () => SplashScreenView(), + ), + GetPage( + name: AppRoutes.loginPage, + page: () => LoginView(), + ), + GetPage( + name: AppRoutes.homePage, + page: () => HomeView(), + middlewares: [AuthMiddleware()], + ), + ]; +} diff --git a/lib/app/routes/app_routes.dart b/lib/app/routes/app_routes.dart new file mode 100644 index 0000000..bde6d89 --- /dev/null +++ b/lib/app/routes/app_routes.dart @@ -0,0 +1,8 @@ +part of 'app_pages.dart'; + +abstract class AppRoutes { + static const splashScreen = "/splashscreen"; + static const loginPage = "/login"; + static const registerPage = "/register"; + static const homePage = '/home'; +} diff --git a/lib/core/utils/logger.dart b/lib/core/utils/logger.dart new file mode 100644 index 0000000..a5b7695 --- /dev/null +++ b/lib/core/utils/logger.dart @@ -0,0 +1,22 @@ +import 'package:flutter/foundation.dart'; // For kDebugMode +import 'package:logger/logger.dart'; + +class AppLogger { + static final Logger _debugLogger = Logger( + printer: PrettyPrinter( + errorMethodCount: 5, + colors: true, + printEmojis: false, + dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart, + ), + ); + + static final Logger _releaseLogger = Logger( + printer: SimplePrinter(), + ); + + static Logger get instance => kDebugMode ? _debugLogger : _releaseLogger; +} + +/// debug print custom +Logger get logC => AppLogger.instance; diff --git a/lib/feature/home/presentation/home_page.dart b/lib/feature/home/presentation/home_page.dart new file mode 100644 index 0000000..2e2ae0b --- /dev/null +++ b/lib/feature/home/presentation/home_page.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; + +class HomeView extends StatelessWidget { + const HomeView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Text("Home screen"), + ), + ); + } +} diff --git a/lib/feature/login/presentation/login_page.dart b/lib/feature/login/presentation/login_page.dart new file mode 100644 index 0000000..9fa9921 --- /dev/null +++ b/lib/feature/login/presentation/login_page.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; + +class LoginView extends StatelessWidget { + const LoginView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Text("login page"), + ), + ); + } +} diff --git a/lib/feature/splash_screen/presentation/splash_screen_page.dart b/lib/feature/splash_screen/presentation/splash_screen_page.dart new file mode 100644 index 0000000..ad2c40e --- /dev/null +++ b/lib/feature/splash_screen/presentation/splash_screen_page.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; + +class SplashScreenView extends StatelessWidget { + const SplashScreenView({super.key}); + + @override + Widget build(BuildContext context) { + // Delay navigation after the first frame is rendered + WidgetsBinding.instance.addPostFrameCallback((_) { + Future.delayed(const Duration(seconds: 2), () { + Get.offNamed(AppRoutes.homePage); + }); + }); + + return Scaffold( + body: Center( + child: Text( + "Splash Screen", + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 8e94089..1a74f81 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,125 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:quiz_app/app/app.dart'; void main() { - runApp(const MyApp()); -} - -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - // This widget is the root of your application. - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Flutter Demo', - theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. - colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), - useMaterial3: true, - ), - 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 createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - 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: [ - 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. - ); - } + runApp(MyApp()); } diff --git a/pubspec.lock b/pubspec.lock index 9999eda..d051904 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -75,6 +75,14 @@ packages: description: flutter source: sdk version: "0.0.0" + get: + dependency: "direct main" + description: + name: get + sha256: e4e7335ede17452b391ed3b2ede016545706c01a02292a6c97619705e7d2a85e + url: "https://pub.dev" + source: hosted + version: "4.6.6" leak_tracker: dependency: transitive description: @@ -107,6 +115,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.1" + logger: + dependency: "direct main" + description: + name: logger + sha256: be4b23575aac7ebf01f225a241eb7f6b5641eeaf43c6a8613510fc2f8cf187d1 + url: "https://pub.dev" + source: hosted + version: "2.5.0" matcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5d797d3..16c3d4d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,6 +34,8 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 + get: ^4.6.6 + logger: ^2.5.0 dev_dependencies: flutter_test: diff --git a/test/widget_test.dart b/test/widget_test.dart index 536c270..efbbeab 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,30 +1,30 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. +// // This is a basic Flutter widget test. +// // +// // To perform an interaction with a widget in your test, use the WidgetTester +// // utility in the flutter_test package. For example, you can send tap and scroll +// // gestures. You can also use WidgetTester to find child widgets in the widget +// // tree, read text, and verify that the values of widget properties are correct. -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; +// import 'package:flutter/material.dart'; +// import 'package:flutter_test/flutter_test.dart'; -import 'package:quiz_app/main.dart'; +// import 'package:quiz_app/main.dart'; -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); +// void main() { +// testWidgets('Counter increments smoke test', (WidgetTester tester) async { +// // Build our app and trigger a frame. +// await tester.pumpWidget(const MyApp()); - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); +// // Verify that our counter starts at 0. +// expect(find.text('0'), findsOneWidget); +// expect(find.text('1'), findsNothing); - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); +// // Tap the '+' icon and trigger a frame. +// await tester.tap(find.byIcon(Icons.add)); +// await tester.pump(); - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -} +// // Verify that our counter has incremented. +// expect(find.text('0'), findsNothing); +// expect(find.text('1'), findsOneWidget); +// }); +// } From f1bb4abd6dd968c49664fd248a05098f7a2fa62e Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sat, 1 Mar 2025 12:31:00 +0700 Subject: [PATCH 002/104] fix: minor test --- lib/app/middleware/auth_middleware.dart | 8 +- lib/feature/home/presentation/home_page.dart | 11 ++- .../login/presentation/login_page.dart | 5 +- lib/global_controller.dart | 12 +++ lib/main.dart | 11 ++- pubspec.lock | 95 ++++++++++++++++++- pubspec.yaml | 8 +- 7 files changed, 136 insertions(+), 14 deletions(-) create mode 100644 lib/global_controller.dart diff --git a/lib/app/middleware/auth_middleware.dart b/lib/app/middleware/auth_middleware.dart index ee77cf4..b486f35 100644 --- a/lib/app/middleware/auth_middleware.dart +++ b/lib/app/middleware/auth_middleware.dart @@ -1,15 +1,11 @@ -import 'package:flutter/src/widgets/navigator.dart'; +import 'package:flutter/material.dart'; import 'package:get/get_navigation/src/routes/route_middleware.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; -import 'package:quiz_app/core/utils/logger.dart'; class AuthMiddleware extends GetMiddleware { @override RouteSettings? redirect(String? route) { - String name = "home"; - if (route != null && !route.contains(name)) return RouteSettings(name: AppRoutes.loginPage); - - logC.i("its not contain"); + if (route != null) return RouteSettings(name: AppRoutes.loginPage); return null; } } diff --git a/lib/feature/home/presentation/home_page.dart b/lib/feature/home/presentation/home_page.dart index 2e2ae0b..68bfbfc 100644 --- a/lib/feature/home/presentation/home_page.dart +++ b/lib/feature/home/presentation/home_page.dart @@ -1,8 +1,17 @@ import 'package:flutter/material.dart'; +import 'package:quiz_app/core/utils/logger.dart'; -class HomeView extends StatelessWidget { +class HomeView extends StatelessWidget with WidgetsBindingObserver { const HomeView({super.key}); + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + + logC.i("the state is $state"); + super.didChangeAppLifecycleState(state); + } + + @override Widget build(BuildContext context) { return Scaffold( diff --git a/lib/feature/login/presentation/login_page.dart b/lib/feature/login/presentation/login_page.dart index 9fa9921..4a6bff7 100644 --- a/lib/feature/login/presentation/login_page.dart +++ b/lib/feature/login/presentation/login_page.dart @@ -7,7 +7,10 @@ class LoginView extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( body: Center( - child: Text("login page"), + child: GestureDetector( + onTap: () {}, + child: Text("test work in background"), + ), ), ); } diff --git a/lib/global_controller.dart b/lib/global_controller.dart new file mode 100644 index 0000000..955214f --- /dev/null +++ b/lib/global_controller.dart @@ -0,0 +1,12 @@ +import 'package:flutter/cupertino.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/core/utils/logger.dart'; + +class GlobalController extends GetxController with WidgetsBindingObserver { + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + logC.i("state $state"); + } +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 1a74f81..f1c8c13 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,15 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:quiz_app/app/app.dart'; +import 'package:quiz_app/core/utils/logger.dart'; void main() { - runApp(MyApp()); + runZonedGuarded(() { + WidgetsFlutterBinding.ensureInitialized(); + + runApp(MyApp()); + }, (e, stackTrace) { + logC.e("issue message $e || $stackTrace"); + }); } diff --git a/pubspec.lock b/pubspec.lock index d051904..ee6dd09 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -75,6 +75,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" get: dependency: "direct main" description: @@ -83,6 +88,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.6.6" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "55580f436822d64c8ff9a77e37d61f5fb1e6c7ec9d632a43ee324e2a05c3c6c9" + url: "https://pub.dev" + source: hosted + version: "0.3.3" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + sha256: fad6ddc80c427b0bba705f2116204ce1173e09cf299f85e053d57a55e5b2dd56 + url: "https://pub.dev" + source: hosted + version: "6.2.2" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: "7af72e5502c313865c729223b60e8ae7bce0a1011b250c24edcf30d3d7032748" + url: "https://pub.dev" + source: hosted + version: "6.1.35" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: "8468465516a6fdc283ffbbb06ec03a860ee34e9ff84b0454074978705b42379b" + url: "https://pub.dev" + source: hosted + version: "5.8.0" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "1f6e5787d7a120cc0359ddf315c92309069171306242e181c09472d1b00a2971" + url: "https://pub.dev" + source: hosted + version: "2.4.5" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: ada595df6c30cead48e66b1f3a050edf0c5cf2ba60c185d69690e08adcc6281b + url: "https://pub.dev" + source: hosted + version: "0.12.4+3" + http: + dependency: transitive + description: + name: http + sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f + url: "https://pub.dev" + source: hosted + version: "1.3.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" leak_tracker: dependency: transitive description: @@ -155,6 +224,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" sky_engine: dependency: transitive description: flutter @@ -208,6 +285,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.3" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: @@ -224,6 +309,14 @@ packages: url: "https://pub.dev" source: hosted version: "14.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" sdks: dart: ">=3.6.0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.24.0" diff --git a/pubspec.yaml b/pubspec.yaml index 16c3d4d..ff99772 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: quiz_app description: "A new Flutter project." # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +publish_to: "none" # Remove this line if you wish to publish to pub.dev # The following defines the version and build number for your application. # A version number is three numbers separated by dots, like 1.2.43 @@ -31,12 +31,13 @@ dependencies: flutter: sdk: flutter - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 get: ^4.6.6 + logger: ^2.5.0 + google_sign_in: ^6.2.2 + dev_dependencies: flutter_test: sdk: flutter @@ -53,7 +54,6 @@ dev_dependencies: # The following section is specific to Flutter packages. flutter: - # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. From 5787cea803cb8c629b195500f864dbcb46d7ae7c Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sat, 1 Mar 2025 13:03:55 +0700 Subject: [PATCH 003/104] feat: login page --- assets/logo/google_logo.png | Bin 0 -> 16590 bytes lib/app/routes/app_pages.dart | 2 + lib/component/global_text_field.dart | 18 +++++ lib/core/endpoint/api_endpoint.dart | 6 ++ lib/feature/login/bindings/login_binding.dart | 10 +++ .../login/controllers/login_controller.dart | 69 ++++++++++++++++++ .../login/presentation/login_page.dart | 67 +++++++++++++++-- pubspec.lock | 2 +- pubspec.yaml | 5 +- 9 files changed, 172 insertions(+), 7 deletions(-) create mode 100644 assets/logo/google_logo.png create mode 100644 lib/component/global_text_field.dart create mode 100644 lib/core/endpoint/api_endpoint.dart create mode 100644 lib/feature/login/bindings/login_binding.dart create mode 100644 lib/feature/login/controllers/login_controller.dart diff --git a/assets/logo/google_logo.png b/assets/logo/google_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..a4a9918dad10832bc8aaa5574d71fe15f089c8cb GIT binary patch literal 16590 zcmZvEc|6o#^!J^y?<5gfk|j&XzSAPco+XupgzU=N*aoFSVMf+$8Dv+4tRqRvI=1Z7 zrtHeT&U5Gcd!Eer_c`Z%&RtGV%uV%K7 zr-gs52M_H6AOuhs&shb(Sejx8ozy!Z{|#EkPYJwWD$rGvKKi-&i^OMIHe+ZX;m9e z$hp$ic-;w!;5%9-mn!R)j{n6M+H#eMM9Jz`zZ2+d1-U=z0TBEo{(W|8M|yAEIk_+Y zMg$85$C_BWCQ0C$X%_H97o$iu$MZoL!g&rbru)@kKAkCUqY# zbDU;C0*9!7Pqs&}tSSEev{Z+l;VqJdGqgxDHo3a86HYKUe+dyEYi~|ag%gzAGd+C8 zaV zQC7+6jW(hO7#7%%JBrRg-P9ruKWgOu^63kQTG43<36Gltp35|&eAM4A0r2}5=ih65 z%-cQK+JI)0RmSQG5-X~1;&{|sMW*);0O;)!|E{!|bzr*~=eqs%2oCYr8&!vDP|E1h zYJ6Bp7n%ToDTLKhG)rjiD9R_LhD$4fA@~MyMfBht@c`nK_VHprI}ob*_oTinJ1B#H zJt4_T-zT5ixbS_Z=+rGiF6Mzx^hhLl5IcqQp*|(s0rgF7 z4i&nYCKF<#2)|Tu%FTytY3Y&nhP!%-nIVr^BWGauiT|DiXN9FiJi~LU_$8kq(akbP z=Zra!^w8Adx&WgKXW=KBB{gS^niveBPvb!GW4-0#=<~;J2=YHRJP9x>5QjNzZ`I1C z(>l-jTkq0264%@BC~uw4ioJIVV0?aY-mSJF-$Jb<4(Xbm{9ybna>bxQBBN(xaNsl@ z5^Ni4bL8AE+M4mP{D+h|+ejaNdM*Sne5sfN5V)bNs+5pIxOoOimpVn`?xofzf`CrUpDJjS1u< zC<&D9V3e*V&*jB^C9()Sxt>_W-a_mO-vOgyRC`;u$UU6g*?0PA@vx9!zs$wIS5Y_|mC!03?J8hiBj`4adhU8HBpSoqFemk`npo!K(ev z#mN`lTLCDB>omSsQm3XpYFSv1u{P-|80cD-3*Z zXmjWcT-?P51~i`m!OZE^G$LU#&}Pu?9CxU}v(Mt+C(@7R1={NX&t6OHG$#~fqI*=x zfgYSlHv7<*;(zAaK8SCBYJ;!UL9x?%bRwE`A4!u5ujU0TgEjMP$L6`a*GzW{sU(jeur& zIYT*^9-NoXd7McTK9gMYe8CHmdwbVw8nXarLf>=Z1T{Db#i>hL=SLO2n?43U&$2cu zW7;1gqkzzDC=Ci5E+R3HwgsmfMV0C6vkJF#keEcs3LDrKp*ahRm>ucW7qvN{P5)se z3j=yY$ZenN7Tp`QfW#-})~^B88t4B+$GUMEoJ%$PU@NNiiNr>5WmRd-g#Bm!;fc6K zE0ha;9M|(Xpalv@*y{e#Wd=?BWW#42dVo9)_3hNRG(8HtWao)^KE&HIn=KRNIEnUaUGGJX&c>I}6j+fmTv6&hZ@fn5^B zHh13Qo$P#=K%V2XQ)He6q~s$&_zeX*i5(YRqD+`RYhtMZGwkoR^O}~>x%loNNMW0b zzL?Xb1rsWKsIFeWpN3dHxz|Mga(jb{^!PazJK!l}^eB}Lf$4+vT#OADofCohU@VQt zNn6m9a)?X6&H>UX7<^P9&yIIeASx>rvFjRmU!V_2oX`;QJx$icz=}E=@O$cxE7h)@ z&tnT=u!_i>>O$_tuQcY4&`}n&{He-Ao$HcZXHkv$cqntI$%-h&nv`2#0Q- z{Vy1h`vAh;hLy(#(l41Hb?=_tPGv{{h9WtS1;yrMv4t`JXc4*Fu<2uvOHKJ~68??{ zxu5>`Zz^Ya?4Yz-d9+2tgr`26fxgh;POHqyr!0+h)WKsI7!DC&;qQhd|DS`B3{k zvn~RPI}$h^;Z2ee0Fu#dGp=a}o+1bUisMK7b+ z491<;D*^DtwCOlprB;m%6`mHEyFwcQcsOavKp1>|JhdDYjlZDv9AKWF-KIN=z>NOF zPXCgi0iN+~I)tlKRgsW5-{5CBA!Ns3ixUXUbEwzaxWlV|*+6C|Nf6mI7#6Uq5`EYM zXJ4hqe0cI(C~)9DIItp~FcSq17y9J3AL*Abun5ilz)mkyy1sKjBFmQ_VKuEpKsh@N z8gS|C;Hxw(nf;zJfKN0G&{r*nEEyh{<=~%VuXwG_SLg7Z!d~(5AH|6dkM*4j{OF6t zmoIuW92fw8Z#d+ypQoVeyjeGQl%jRczi0lwCZZ)ZCyHWP@;Latb4Pj9w#2rFlFN8G zMPvu2DEdW@%4{mNuW@rw!>?z5GV5w{v>FnNG`TRXXRzyuczqEqi7x36X!(quGh2HP zC$f}7pv#88aMD@sKxMXckOc4b(g5gycThoRy%=&7q7SC90yWWx7of6GU)6iE8}BCK z)A03SJ%M3iST?zB4MMTt8)#l{@evbqD1wW zY>OGiObyq12Pos=KLpF*8wf z^$XZTmqFv1BQRwkj&wlX_SAm(;U(vA1Z~1Jc<`Jfx;6T|ASE-W-Rt#;fM4*=UORk> z9f!QmI9{*0*teT0dXR(1i3~UT&giWQa$bbgtrywK86U@lAShwAXNy7Xf*VQ)x9*q9 zy}F|ZGd_k`(^UTyI~~kGdRwa+M412WvAkpR05X%Hw~cl6FSdeyS5Ct)os6ET3ru^s zFzVnW{Fd+c-yRS~nHmICmQPJ__|`uwB&Sn$YPdczA^kfWYhxzK7{$G2kk}^pjTN*= z&RLkznAhtV?Pg5Xb}}AMTed~{FeKkICYU;lG zZYFWvnKm4bst_Ig7C(5tpNIL7$NCQK=TPCB9%iN!eU=QKX~+xb>7*$aZ1VlG}~TtQg!VVmHOV_%PhkoTkc&+scBw$pB~|Aqmz$W0|VE{kP5 z7QglU-N5nv%ZWmL;KmnH!qYVAa6!41k39RAy#tj==TYKDk~Z=Zv=K+86BR(<-<}*z zF5j_cY%i3j@1GsU5GK(IB9O1{iZ&gkjW9MKxd6|PtsYujzQa}cJ`|cNT4!Gv>ND9t zO3TKfo;no*!N4?00fJnGUx2+0#BJJ8Y)-M(nM=e_ z)S%XLUz0}s)|*PfbCAb-ybCgWDv}v)F6)S9eIO;=q8~qnDdpMEQW2TUpG{V#ji9Dv zh7JOjsNDf-88xY52t>z`wBr<1C&UgMf3-IL!EtWp$-a#W%$fY;MNrNjU3b7aKzn!A~?caRsiS!03YG=eFQ>m(UL=9 zak0)#yKKe>Q@p710ncX-%;?`jF?$4LUwkELL*3jvmxdrswT9f3TZ!k{|AuTVAz$R9 ze%vPb>-z#_Mu*2xb!bU;mjnYet_M%6dJ0ZSuQ%0j_gJ*f=e9b8ZiK$mMoDFF*e!ge zdu`DXN9QZsIq$Vy!D%fW9c1uca-D3B-IP_V;`r)Gg@gM@m=h&GlHD5K3R28Se`0Ut zGu(WkU7L#cI?uprhEUFp_%qvi`08tOY~a_A(aRe>SXRXxD-+v-E?4dHj~CEd_7BXr zEAUJZGxjM4N*h4J@g3%s1nQcIYnFeKD7CiM_`Y+CdPE5;0|ED!1j*?u#}J!AiUP88 ztY#0DYHoXMOumVZNx^Zcl3I@T%xW*APXXP6?Yp!jMzbzj_gecr30IxU7`20Jb!u8D zuT@&53)t1*wU?a}%#Jnr6XKv!-ea5+L*@7CB%YG67t~MztCSfRpgk)w79VpVXK+X% zwFJ#*OGEX2Z&Nw~=sw(;E%%Gi&qr;q-99<0IH_gr;WLGz>KjGRllWSb*S)XoQDJnp z-DpYe4{1e)lVtR!)yzyD;j{*eym9LSFfmwVv+oA$L5ki|b9yL~?9X?3U)13m$W_lT z?Ox?3ac&u^KbxEdB-!wEM12)cbn9h}kjTR9M)S85)iMOSStGO*oXNIX$BY<6Uis?} z`~2+B3R?ExN?M<$9Pa$=I^*RT%j2|bl>RMc) za&Nrfj2t?;ZuC`3ZFx1!`oU2KD^%bT-mbtEOs_~tB$X5jm}m!NXH0*!TMqH>D#vR7 zplF5N%9A#?HUF*$N|xJWgB;APU6+pzuX`Iay0anoK^JXIiJ^oHOs@SY0$^ zKD@N#9jCa_L{pO0)qoMc=3KXTcAH99*XpP7)Zw#B3TwXk_;Ri1&KAS{rMqD^s4$#T zePRHD)Bh4=HrL!008UsIAIGQ|jt^R1cd>p;<*pvyBJ5*VP@-PTc3kbog*7=0fN-$7WyhEdI??+D|TN}s=N~AL2ioM z6{y^rD%j~aMA1R2~8yIpl2nIH__VC8(vc8 ziO-hK`7IjO{~V_IRgzoG1hwoVf~6Tvmy!m57ta}(R=@2|{?rIJWKxT00sCp^FH{a& zhhzhyqFF(Xb;7H_5yN4<1;tg*e8sDOIg_R4GVUk`MK{*5fp3?4k7G`WE-gy1JRToo zQrvj)u;i(FqYO#n-#(RYaIyjjPAw8~D5<-iC$SQKorP}w;e4h;{4`(BESlZ%ox?jvO|lcv*)}MNYspDN_?lLXhfY))gLt4x?2Tb1G*bdBnLS`)Gz8 z@<3!CfmxN*2dm0vD1+hN>t)5iWz3hl&C8hT;f8|N5|r@4dDQRHJq_N{CE+*xn$J%v z%U>;;ZVa?up)xtw-1QLaLfHy6V*x@(4YjCz>pkEo)*88mKW3%=Z9Xq4VftAdl}a(r z`seDv*JU?CF~IL;_(l?;HWDqenv1tb!)fMO%mdO=_K{92pK1%s-f&Zvy-m7bA7x=8 z8Tq0XfOxyE5ehN%Cau>Oy>~pwsvh!ly~z7-=}Ox(sIR7?qkvt#;03~gynSijsn28n z+I?%YjbB&JtyPfa%_M+Ovf**?;-^HUd*Jnw;@?fL8m4FuMtEA`9tIl)<~%Dbptv9w zpRdIgtT+5qDYj`%!bJVE3e!?gBe|3UV!z}tvu<_z-_7Lj5yqPY741@qqBAxDmK}l& zX+h+&Q2-t#O9Isa%c#r4E#7&B+12I&8dp3^h2TnxaISz61Rv_KS`XSt-Wv&3I`P%^ z2{}863`n^z=>XJWh{1S|Wn@q3!YCe>edXs*8SCMc0KUUiO;I?qR0p%}d&{V^r4wWt z6E$cewK88$MYdk7e)s4<$)xmvZ+sE;4+~Da3W=5bH#e&345xfY(S~pnRS2vWmwbGi z5ju~{NdA*lV)V~|q~k(Muw(?j=Py%X4xfG05?%UPtlP%>EclA$^jqWMq?bNiP0wCH z;VN$Rfz{tS%?^UFZ&3<_{AOvCzd77xeaf* z6D8IZcOEEN^rQ(eNMrpoDFeK?MGN$U4b6{jJkqQzCa2o;)SOv9vVB)ElW(0OoMkda z*#jpILK2IR&Xx*J#n7`}&ToD7+$7|LP(YI$Va6Qa=hG}WU0Q;g$_O3TG*NGGts!sb z!F`+ulriwPXuzZUhUUkI+uc=nubFt+70wrq-e&tbr2|Nfp27vuuwfMadO(cC~*?)4`q*FBY+WTdnpt-z6)+0tOMpv9|y zvX1eBMaVURWv_hZ!k%OU;CfV3fEzv}!H1frvH)Wlc*lD|=8m-Ne{A?uT;C6JbMH`zzHMqL2x2i|2EC57gu6_3J^=VyU84fW5W8%Y$rNk;m(!NxC9)Z< z#*t_MURc#?0uHEt2aaaw02wzwnQ$M0zCV*gnU;#lnV%I*3?KtqL$vcc5~#XeKdEA8 z0OY!$Y*+Q5Y`D+LROaURNG#=WPQ_ZOE+8Q!OdtWgkETpTY55xFJQ8z>GJp~spjXle z5M%({e*N#I%I}BNKq>o()r~MCA05hj-Yw+8c}qmQeFMyreXkz``(y*56EQa^Gr4gs z5K%?X4nRaP|H?2sctDi^=kN97W^Ugpj4zKr1N~P4uH3m2>7#+ zoJ;)JyvqsLC7rKP=~7y>R?!kJ2UMs4;1K#>V+kR|e~pC}Ua)PzM6!OG7GPe(udn`q z3N$X_hP#>_!h}^J_;t|JkieEi;E)AXBKPzr)b;;<@PBmV(9OsIM$m>NI%+u9*&iIb z!JZ3fDgZt{vjgAG4sJjM9zET=x_urAdb78Rj#C6t^_=W{pP&Jt0rB5HMo10(Z%&SO zKbkinD6(sC62Y*$Wl19e;PkMF`uRtSHF!+gt~0TK6v!=Zwoq^^Gd-C6z>N8^`QPW4 zox7+{Bf$(hi{e|)B)=%@zNYW5qu7p#^8`)RC#XsibmICV6*i)Mf$cx* z`VqcW$v_QYKD-+Jj$#2tWe5V2qEC02152YJuUd=%2h_x!BVc>&RM(dZ;MU39%AUEEJ1>?fng3&JMX*g_$$!ewBF>kR%9=Xp zfxy3Hg4wjG570eb-isHOVrqU3i5TW7B=JX3tG&7Ukcb(x4{ef=7_tHHdh&bO5|OO) zu>Bt^Ws1oxrLLzHAzS2LN*=u4!r zSuaczfbJ_zrKHT_jpU;-0YFEYj3wpcT;6}o zm}uz9|1c9GX^v`1L)6b$M#a8kpQMAA1penPK(kLobDhV`Ml>1iN<0O`NbJ0QL1Kzj z=4F83Nwc_?{IYEKQii|RR#HPri0j7F;D$|>*Z%ih(bT$;tS`TXI$MizU6W5@`WJuX zVHFdii_ZL9Tue|uUE{MMlQ3JadEt^Y8X$-02HRgA6q2n!90xycnlXI--V)jDzg!Z% z{>R}*%3F2FaZh+0&sO!DB4*A2miam&(X%ftM! z_I>FD3s#WX-WWl?@Bh-qs>}|2)X@?>oo?MeI6M-TxO_ZKahm)+47&`o2tr|VI8NzR zG6NmZ%S2F!{u&W!MiaaIH*(5-2m>`}|+dlf%}IP-=(C zUlQjoPZ2|MWfsTv<6@Yyo6d$%&HRv{!CY3X3&`xyyX(Uuc}EXopgKT?v?nz3)YxJo zBR{~T&$L4|_}j~?>epavP}%M!e|Ed`K@nLGurtdt_YCQkCJYJg6&+hZn-tas1sfs= zadU7scpe+8A&?7Wg7yN4m6~X+?6q&snPpKrx|h>3WQhbf8O#_Y%TLd3#c5FqxgcVl zeDSE6d9@qGO4UOOwP(VA1O!WGX%c73K`s_5zs`jYBO7q+$4j_qyVKPl*x=v8v8%VD zT+pKGf`Dg^nTCp{xxURV3RlmLS03fh+(>rl7Fe9I{2jz!)*D%PPjP(0Y3^q>Od^Aa zZQbZ*?yKTrk2;I--w0;*8T%Nu%Y`qckSi9au_KZ|$+)Qx#Az4I=%E1X{ zH!i=(3@~zCueeH%C@u^<{3+2U7QkjUTGTL|>r*jtu03=2B~dNz8UXph`S_P!TIUCj z{@Q6ZlcFRbc^{0w+8O!}Xo^5cP3Vi~I9xZ~4_2*uDZ^#e! zdo8~wcG7P4@079)gi+z5{0A!&3UEablm|EcZ0pnnf9j&gQ{_8aURltAE!6czi2y>+ z0xMC^F2v*V&YNAX!GPEP#8f6OkUS)+Sn0!c@FELKO99ViWN#}wmVM!PdP(WXiSo*_ zth|>Z9u@IZC|2tFf_LQ_l5igVuiv(ukRrag&%Ct33b<(c9LBtP^ zVFPmqLQ6$D7gIex?7l4$p-y4;Dq<^FJKs7#r5;~bH@$Uh?sjhNS-{gcTkEZ`6hUJ$ zYkLrxg{V4|bSo}WB&hGXE=*QLZ}jV0rg?iPh8Q%sC*K3#d<#WJ_O?g0U{ESLYGac^ z@z~MZa=TB9gfW+_3UVqnE(+dUQ!l5VJ=S_YR5iu)V^q)>P%e5`-k1?YZn1e&v->Ob z&ViH}icltyU12rKx4!gNgIcuI{&kukGMvXqJnKm?(62E>^Jgu21SQXZ|9l*zU0}ZX zFPZj(Wpwp=OT~qNQC*Q&b#j;K40bP8`5*{r=xt&%5qvD)J>ukxKbaw_nOFUnnonXp<4g2@Xw{0Jtqm-s6D_RQbz9bY!+A#Z+CIs*o=afy1SU$R(j%z2r z_J8A2V+alhCr1vhpo;pHB3J3mP)XMFUKN!BRGRWtk9zIH9xn?zculA_UnaUz1$1EY zp*-RfLFCoe8<69#&X<;1fmIdMnpOeV>O=tD9JZqQ?bocb@Qy$|$hz7$>Zl(OT}778 zkJ7&H`BncZmKrFF6Y8|MoQD+Tt#j=BYAS3sE<`_XN+gxa@9^K66M6Rmk>l>YFl|rN z|C2-5_EM9n!AebqOS)wh)i7uCjbG!g(7qA0ZdU#wLh#ar!7l#}0+1=*Fp=(Eb6=Ab z!RBI-w7VEKhnlI{Tsm=Usw>Ype`3uMbPlRsA zOgYSGre){M&Ga~aJ*pjaZQL4dkM;~ZL*-}w-B!bP!iVV~3#sj5bD$&bX0|IK*S#Jw z)@%E>-S!(+hBnl}VN=R>A0EjUj>|A=_9yMp?9X4PB@-kTBJYPo?^nk(75PU|e5WFA zfmku>wnP8U{j+B*UZ;bIsy%_=>Yp6(66mzkouK<%G36dx64)^o?#D97>TQyFr3bZJ`J~(=xXkh{BK(%>HTWD(^?j@=l!~tT3b!kh`UVN zip=vkc-}m4@9R?JhN6}^dh;Fm40fA_M^hzbFE)&W!ZR`okG=#;ew-VD?(=P44ege8 zCS3a#k%kBE3ahrqT^$Yg*{p~T+NvPfPTbFll^%-yM&8IkI zc^8Uwae!n~h^lj-luT5S7A`jVAo<2jdQ~<(ShJWSrC>4vH<_t( z*&$h(1oj+#v`;-D2-ym2I2CFCyHBU}o1pQPR(lG6{u#G5icuSozbGS44?a@nPqj4!*td+E5xvVS2ReE$+cU)cUW*aUY%q03yQF5Yh z0jGu~%xK;(s-l7@S>+#RlC|;PBVr$%<-FlcTBXWBP;D*w9I+W5gN?OemtnXH8T{gF z2Ltnc=}kUba=*kpL;XX>^T)Exf(_XN8z+QFs%YMdH{yHgVTUWEOd%`zbgQ3dbtV&6 zs4%@Ffw<$VtZ=|y5w&+(DW*3K%-WS8x)T#+uc+I;DO4nH6&y|0Z0JVTcWLhJn@VTx zt9LTAbHOInOZj~l{;c=!B;0rUk($YP5%iCZA0~#e(yF9;MMc0o>hi9j#s=kShCdw=ME~Oz0jF&xg3@`%sI4 zKb~GWHgh#kUezBD%p}pxF+R}qvkjumwJT=l0!l=ip|aB1A}L&F%C5hccu<44&bFxk zuphO5T{=rKovLVHjs6X^etyv@iMb={@KfSZB=OS4T+}tZa43~!cm_<0P27O`MSKpTUqyW$X$4J*v zf`6?5Ig@4Elw#3+lQEf#=z;{4wav(SuUlGjtGRV_Jel?HMq|W7((&Z`&uZar@xG5T zY5J=AeYvH`d$9#Lv8O(!L~eXiOy0-NVXZX_8=aEuk1IlM z3o1~p@}r7-M>b<~+Mce$Pc22lE)gFsA%~w@mEP?*USfa$22c3Gt-wlp-1-frd!q25qIXHipyjG*qOT0rh>o3RNA}>_B(OZX{k;oxU$4Ir2?w z!|sgx)sd2tr0Zt<$X^VoJy(mQHlZ8-udd{MZCVYgZlI<4^SY6?<-8DP-~+L1F7R&7 zHT@&Ue;JA4Ya`5*&V;v?(5kYi5~i_m2bk&euSedw5GS$0kw=xQ2)fQjQ z!4-HR|MrY{ar~E!(xEE(;utP*h4kYmTx{bc-RSk~ybZiccl73Mm$$9)j(5r3Kl-Tt) z#Y57**ms>`mw_t+X-J}xWVzPK8yloe_v@ig+;>&~5IY-0df@KIPOe>W8Wm7*-m1bk zYd$A@=&nnk3nz|^w?x%4S6o$BvnB3er_V^tck-RppyEw!whgfJSs!ZS)`lQeu-&qG zfR3Fs{WaD8ZsK&(O;l^jdNVY|5VxNWFw~d?qRE=5$m7-?jWfB0qU55IB(xd7Hjl;(7rk;Eo4Eh}+VNSdfo8}y--Tu#C`|l>nxdJ#R$hcW%~tI}3P-`a zSr>?>7DdwPud2J%&hbkZ>tWTLMm-UMVmaO>D=itx5jiEaMfc~;Eh4+NTGdRKaBzJ$ zoAvd-n_%fdto+J6e<1bRpBMDJ28q-s==ayiY3AK0RmMx)CGK~mOnO`GhZk;gck|H; z`2G8_pipc;q6!{GV#>Wz20w&Ly>DvjJwV9(#W4ol-j0mLmL;a|V0r=a2XV zX_XAue`g-9)O@z@THt&nnm-w(C&1L+qq>!KtD1VcBU)j5QW!-}T<_nt`RdAWYYwkw zMAltn3XC2Vh`-}xGwPSdo0?r(k|*yX6ZqvfgreyEy-w3Z2N*I-MIIJ%ev8*r<_W$? z#3`Q~z-5&$eTgQ5%`#TMXNR7dc#jnpZFQ~Z_R>mJ3PUy9R3*zf9MAavC3JeWkXx!I z6H5`8YpS<5y<6HW8L0atQUA{^KWG@!N65zMbuVUzn)ny>*Tx65y(c%MQNl=FIra>Yg%8h0x=hpHCWZHNp`=PRI-6jv$b4fc&dJWxmO zi7z0)1M7g9g_~od=1K(46y!ti`H1FN{(w0;CLi?oo#REzPFDg17Vc>P`pTqnuKApM z3GREY#b^f&G&=(QGV}!{Ma9=>-iX1ix<~?4%m55%}>mWl~4U$;H{X;v#smK|2=#&BYRJn zo~j%!*6Xws@5B}z0q5~(-#wdJ#*y`<^L5X9r5|ev;bNQQ+`SA`b*QRtLL=}7t1oFt2JbEY@{l%AvDeg`jR^m_0h=NgpJtX0d38oV!czZ#7b9P*tq9IlwM zv1X=QIMBq!-uNZKa?%AfwTR5wz)dImyhw?N!G?4jkNRh8uSB$*rw>95FT^T=dI#?0G8Iy6Cznq>k;5!(~?LCU$ZSJ?62Pt9RZSOO)({z3eg&i|ccV4q+ z04FV)O%E)WuMGQMR0?Z+^wzb<@oFa=x+9yHjvd5P+6UkHSiF~3i_Tw>JqBW3E=J=7 z27E8veCzQ$X36Rtj%IV+Sn^&@(MJwy;G_tjRz_Mr>Nq~%?9+$ zr&Mfl-1cQ9ZRJrzFN{3jjpw4=&Q8O$p~%GX^qgVy z_PB75kAg<`z6a0zy+xaA0Cnt$JrwRr|2YP#UY8`PE6Hf+2iWP~pO+-{VJTtS;I}p- z5+f`O>!xgm%hfJDHR`h(HV7-Yjvu`31MLakl?$dp=q5ou5K7(mf-HZ0-zr5rG~TQG zq#$Nv1h)s~i`(EqM>04EKvM}i_ziFBjaTE@2VaJTnHDw3u9`~0EJ)0elW+jC+l>w7 zVc7_e;?Zo`>W~A$wIM<@yx|U|gbM^t%oViR*hS97Y{w* z{w#Z5%rDnh;Tx|z?l3BB=e&TSFPqo%7(zGwYc*B3L{x~{-F8q#voWP#;y5=}!s|V` z&+VPjwGwe8I>M12d_J9Gn$av5A)lk}HwdbZL+Tx_**;>uwNgJP%R7a^TS9&*&mXxJ z2irl%~D=Bs~^}k>rs9|4@F-2PDFtZ-dfpW1Nq0@(<<9gYgToE zttRD6e^UGn#kk(JTSnh?qRo8|QPdZ;yI>FXR1#C@M#RQhR|l@J*nWUkt&NMFG*jTB zi+;ui^EOM-snf+>u)qRiV_od{OJlg|`^B{#`OKfcFY^|`l9*6oIA?u?%Q>SlvmO9i zOBU4DpA>eiMA(XPrR$4xaU(DZu$w87DKR%eSi}KDv`){Nz-VQ3C;q$CTro3G4kZog z=15Vtfv0udcypbYwpM@Gabfk_DQSN-J>pwR6^CoO7tv=9#_RSv-MLkILMNQU7`Da3 zl;{u3QdED$VJl!X7tH%&6bH*s;LHD8exih%T6qZv;rlcDPYBRsAZ0(r+Z#!K9=HZb z3Z){=khrvrI+NKv05TV*CGXT0t`k%LD`{Dazqa2^vJdYD)bS>~>zpVY<#D9zGXtGw z*izK)8s(L-t4E{S1(G=`fl^n3T!I!Rziq>+01#Y8;wIyyN+b0dO3BUXFsuv|vNg%O zR2qrFz%;WQ+am5(@?WV=c53n>^wh+P5=|tA9ddVwfnlZ@Ww;h+`4`z8$*mDpv_jM{ z74MN8&;KMqPk0m~8fHlBVm>^CwG^Kq$S@N&uo!|Pnct0DY&>MP`=-+k9#Eu9cbTvu z`cOLK3TVVr@$4VKIuv#&fUCi7A-?l=k$PI2j%pgh*8mcd32{r5|2M36In7($ zzcL9?pKv2OoCLm*tu!aRk57(6@#Idz3zX+V9F8Mn0O2y^It|pQk$OAjjrV;NcOYE* zKPzOCL4r=`#+;&IHpB{O0+_Qq#rTB8A@OZ^?Ia5L)vQ`$wMB|!jv?e?e|O)ymA&zZ z4gn6OQcQc_iZhGd;Q}Lxb_)K^Fz#OG71g8%Bn~5SGX<2U$kJjlH3_@7G|-&TzwqsX z00@RFjnuJiI!$Q^g3DNbOeOArO61IW-o!=ebO^Vuo&tn4 zh`5tz*x2pemmL`M-NvawvQNV(3#`?vhI@(5YqX&^iPG0u=^yT3Wgu#;510L!5a8pP z6w?u-hKm-lo>96E+?7cCcp*vnQQuoi>&Ik5Ae&Kbn z%#Y%;&#*G7>)J>Jp)O`|fRwWYbzz_q34k74z6Z&dKI-Xmz|P;fM&Q0Aa&U<#4tE@X z4~kxe_X+giX-rU(KTRYszq~+qNLkPc7D-i|g=LN1w2=fg?7W*>t~Af~Sut2Bsn&Rj zAbqq6-YUF);`9>)sx{G{g-YjR6ada}oT9JmF9lN+aVd1MFZGFR#vgm3lDXY1;<*su zbMGw2o#*Rhk)8rNOqBHNiJg`~xVVWI`w+|la&LLMv_Wke5dVhiAf0YmY}}Ncvw=)- zV&q}eUncGr3Kb`>hoe@K6tiw3ZaQ5hdbyBRJiYpeil=EQ)uZrqS7gqZkNJe|3gPbk zy-+fp=8XPpc=TNdu~`tclb++p^8S%f{VB8NX-_-@yQcEjG=%uHxb~e;ZJ*_QJm5rM zOoI&c=ZrP&(N4R5&k3^`pPe!^&979zn-y z=298q$!*??nE0;}hdD;>P&Rv$-+4#q0edi9$K2))&BOq zHQMQ0_SHKw1XZoubYW`^sm;6e!tkvubPcTk3;Z$E{}eKl9FHv>SX+E#-lg!dragk1 z83C#yq4j!MvRAUvcN?GM^!j-M7d85GvUSjN3|eu{3OZT{UM#`%K!$pX{f>S1rt*}C zSM^+LB#__xms1tDwR~1q{%;!%%mGxto{HZZ7ZOs9Rich`nx4`Tl5n-(s`h1*5e7i) z)2x8%RpL1;Ym$F13*+`qNfYA*Ly(TO!yWZ!i`>&)3o|-R>V!r^HvTpH`E8>iMH3j7+1m_krJrHDBoB^O}!MP=Ae@ jWXk_U@c;dT89?ol<6kG*Wt*l;Q(B<(OfOcPcZm95Qv_Qy literal 0 HcmV?d00001 diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index 88c40c8..aaa8fd8 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -1,6 +1,7 @@ import 'package:get/get_navigation/src/routes/get_route.dart'; import 'package:quiz_app/app/middleware/auth_middleware.dart'; import 'package:quiz_app/feature/home/presentation/home_page.dart'; +import 'package:quiz_app/feature/login/bindings/login_binding.dart'; import 'package:quiz_app/feature/login/presentation/login_page.dart'; import 'package:quiz_app/feature/splash_screen/presentation/splash_screen_page.dart'; @@ -15,6 +16,7 @@ class AppPages { GetPage( name: AppRoutes.loginPage, page: () => LoginView(), + binding: LoginBinding(), ), GetPage( name: AppRoutes.homePage, diff --git a/lib/component/global_text_field.dart b/lib/component/global_text_field.dart new file mode 100644 index 0000000..5529475 --- /dev/null +++ b/lib/component/global_text_field.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +class GlobalTextField extends StatelessWidget { + final TextEditingController controller; + final String? hintText; + + const GlobalTextField({super.key, required this.controller, this.hintText}); + + @override + Widget build(BuildContext context) { + return TextField( + decoration: InputDecoration( + border: OutlineInputBorder(), + labelText: hintText, + ), + ); + } +} diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart new file mode 100644 index 0000000..e46fda5 --- /dev/null +++ b/lib/core/endpoint/api_endpoint.dart @@ -0,0 +1,6 @@ +class APIEndpoint { + static const String baseUrl = "http://127.0.0.1:8000/api"; + + static const String login = "/login"; + static const String loginGoogle = "/login/google"; +} diff --git a/lib/feature/login/bindings/login_binding.dart b/lib/feature/login/bindings/login_binding.dart new file mode 100644 index 0000000..8ca14ce --- /dev/null +++ b/lib/feature/login/bindings/login_binding.dart @@ -0,0 +1,10 @@ +import 'package:get/get_core/get_core.dart'; +import 'package:get/get_instance/get_instance.dart'; +import 'package:quiz_app/feature/login/controllers/login_controller.dart'; + +class LoginBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => LoginController()); + } +} diff --git a/lib/feature/login/controllers/login_controller.dart b/lib/feature/login/controllers/login_controller.dart new file mode 100644 index 0000000..ebcd649 --- /dev/null +++ b/lib/feature/login/controllers/login_controller.dart @@ -0,0 +1,69 @@ +import 'dart:convert'; +import 'package:get/get.dart'; +import 'package:http/http.dart' as http; +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:flutter/material.dart'; +import 'package:quiz_app/core/endpoint/api_endpoint.dart'; + +class LoginController extends GetxController { + final TextEditingController emailController = TextEditingController(); + final TextEditingController passwordController = TextEditingController(); + + var isPasswordHidden = true.obs; + void togglePasswordVisibility() { + isPasswordHidden.value = !isPasswordHidden.value; + } + + final GoogleSignIn _googleSignIn = GoogleSignIn(); + + // Login menggunakan Flask Backend (Email & Password) + Future loginWithEmail() async { + String email = emailController.text.trim(); + String password = passwordController.text.trim(); + + try { + var response = await http.post( + Uri.parse(APIEndpoint.baseUrl + APIEndpoint.login), + body: jsonEncode({"email": email, "password": password}), + headers: {"Content-Type": "application/json"}, + ); + + if (response.statusCode == 200) { + var data = jsonDecode(response.body); + String token = data['token']; + Get.snackbar("Success", "Login successful!"); + } else { + Get.snackbar("Error", "Invalid email or password"); + } + } catch (e) { + Get.snackbar("Error", "Failed to connect to server"); + } + } + + // Login menggunakan Google (Tanpa Firebase) + Future loginWithGoogle() async { + try { + final GoogleSignInAccount? googleUser = await _googleSignIn.signIn(); + if (googleUser == null) return; + + final GoogleSignInAuthentication googleAuth = await googleUser.authentication; + String idToken = googleAuth.idToken ?? ""; + + var response = await http.post( + Uri.parse(APIEndpoint.baseUrl + APIEndpoint.loginGoogle), + body: jsonEncode({"token": idToken}), + headers: {"Content-Type": "application/json"}, + ); + + if (response.statusCode == 200) { + var data = jsonDecode(response.body); + String token = data['token']; // Simpan token untuk sesi login + Get.snackbar("Success", "Google login successful!"); + } else { + Get.snackbar("Error", "Google login failed"); + } + } catch (e) { + Get.snackbar("Error", "Google sign-in error"); + } + } +} diff --git a/lib/feature/login/presentation/login_page.dart b/lib/feature/login/presentation/login_page.dart index 4a6bff7..cdcd2d0 100644 --- a/lib/feature/login/presentation/login_page.dart +++ b/lib/feature/login/presentation/login_page.dart @@ -1,15 +1,72 @@ import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/feature/login/controllers/login_controller.dart'; -class LoginView extends StatelessWidget { +class LoginView extends GetView { const LoginView({super.key}); @override Widget build(BuildContext context) { return Scaffold( - body: Center( - child: GestureDetector( - onTap: () {}, - child: Text("test work in background"), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Text("Login Page", style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), + const SizedBox(height: 20), + + // Email Input + TextField( + controller: controller.emailController, + decoration: InputDecoration( + labelText: "Email", + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 10), + + // Password Input dengan fitur show/hide + Obx(() => TextField( + controller: controller.passwordController, + obscureText: controller.isPasswordHidden.value, + decoration: InputDecoration( + labelText: "Password", + border: OutlineInputBorder(), + suffixIcon: IconButton( + icon: Icon(controller.isPasswordHidden.value ? Icons.visibility_off : Icons.visibility), + onPressed: controller.togglePasswordVisibility, + ), + ), + )), + const SizedBox(height: 20), + + // Login Button + ElevatedButton( + onPressed: () => controller.loginWithEmail(), + child: const Text("Login with Email"), + ), + + const SizedBox(height: 10), + + // Google Sign-In Button + ElevatedButton.icon( + onPressed: () => controller.loginWithGoogle(), + icon: Image.asset( + 'assets/logo/google_logo.png', // Pastikan logo Google ada di folder assets + height: 24, + ), + label: const Text("Login with Google"), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + elevation: 2, + ), + ), + ], + ), ), ), ); diff --git a/pubspec.lock b/pubspec.lock index ee6dd09..e3bb302 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -137,7 +137,7 @@ packages: source: hosted version: "0.12.4+3" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f diff --git a/pubspec.yaml b/pubspec.yaml index ff99772..45f96f4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,7 @@ dependencies: logger: ^2.5.0 google_sign_in: ^6.2.2 + http: ^1.3.0 dev_dependencies: flutter_test: @@ -60,7 +61,9 @@ flutter: uses-material-design: true # To add assets to your application, add an assets section, like this: - # assets: + assets: + - assets/ + - assets/logo/ # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg From b81ebefc6fcc6677d3027b30340bdfebcc917d16 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Thu, 6 Mar 2025 10:34:37 +0700 Subject: [PATCH 004/104] feat: login session with google --- .gitignore | 4 +- android/app/build.gradle | 12 ++- android/app/src/main/AndroidManifest.xml | 17 ++-- lib/core/endpoint/api_endpoint.dart | 2 +- .../login/controllers/login_controller.dart | 78 +++++++++++++++---- .../login/presentation/login_page.dart | 6 +- pubspec.lock | 8 ++ pubspec.yaml | 1 + 8 files changed, 102 insertions(+), 26 deletions(-) diff --git a/.gitignore b/.gitignore index b6323af..69216e7 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,6 @@ app.*.map.json /android/app/release # FVM Version Cache -.fvm/ \ No newline at end of file +.fvm/ + +*.env \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index 2648e2a..c913db7 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -30,8 +30,18 @@ android { versionName = flutter.versionName } + signingConfigs { + debug { + keyAlias = "keyDebugQuiz" + keyPassword = "uppercase12" + storeFile = file("debugKeystore.jks") + storePassword = "uppercase12" + + } + } + buildTypes { - release { + debug { // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, so `flutter run --release` works. signingConfig = signingConfigs.debug diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9e55388..3d04507 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme" + /> - - + + - - + + - + \ No newline at end of file diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart index e46fda5..e75ad03 100644 --- a/lib/core/endpoint/api_endpoint.dart +++ b/lib/core/endpoint/api_endpoint.dart @@ -1,5 +1,5 @@ class APIEndpoint { - static const String baseUrl = "http://127.0.0.1:8000/api"; + static const String baseUrl = "http://192.168.1.9:5000/api"; static const String login = "/login"; static const String loginGoogle = "/login/google"; diff --git a/lib/feature/login/controllers/login_controller.dart b/lib/feature/login/controllers/login_controller.dart index ebcd649..9fc790c 100644 --- a/lib/feature/login/controllers/login_controller.dart +++ b/lib/feature/login/controllers/login_controller.dart @@ -4,26 +4,35 @@ import 'package:http/http.dart' as http; import 'package:google_sign_in/google_sign_in.dart'; import 'package:flutter/material.dart'; import 'package:quiz_app/core/endpoint/api_endpoint.dart'; +import 'package:quiz_app/core/utils/logger.dart'; class LoginController extends GetxController { final TextEditingController emailController = TextEditingController(); final TextEditingController passwordController = TextEditingController(); var isPasswordHidden = true.obs; + var isLoading = false.obs; // Loading state for UI + final GoogleSignIn _googleSignIn = GoogleSignIn(); // Singleton instance + void togglePasswordVisibility() { isPasswordHidden.value = !isPasswordHidden.value; } - final GoogleSignIn _googleSignIn = GoogleSignIn(); - - // Login menggunakan Flask Backend (Email & Password) + /// **🔹 Login via Email & Password** Future loginWithEmail() async { String email = emailController.text.trim(); String password = passwordController.text.trim(); + if (email.isEmpty || password.isEmpty) { + Get.snackbar("Error", "Email and password are required"); + return; + } + try { + isLoading.value = true; + var response = await http.post( - Uri.parse(APIEndpoint.baseUrl + APIEndpoint.login), + Uri.parse("${APIEndpoint.baseUrl}${APIEndpoint.login}"), body: jsonEncode({"email": email, "password": password}), headers: {"Content-Type": "application/json"}, ); @@ -31,39 +40,80 @@ class LoginController extends GetxController { if (response.statusCode == 200) { var data = jsonDecode(response.body); String token = data['token']; + + // await _secureStorage.write(key: "auth_token", value: token); + Get.snackbar("Success", "Login successful!"); + logC.i("Login Token: $token"); } else { - Get.snackbar("Error", "Invalid email or password"); + var errorMsg = jsonDecode(response.body)['message'] ?? "Invalid credentials"; + Get.snackbar("Error", errorMsg); } - } catch (e) { + } catch (e, stackTrace) { + logC.e(e, stackTrace: stackTrace); Get.snackbar("Error", "Failed to connect to server"); + } finally { + isLoading.value = false; } } - // Login menggunakan Google (Tanpa Firebase) Future loginWithGoogle() async { try { final GoogleSignInAccount? googleUser = await _googleSignIn.signIn(); - if (googleUser == null) return; + if (googleUser == null) { + Get.snackbar("Error", "Google Sign-In canceled"); + return; + } + + logC.i("Google User ID: ${googleUser.id}"); + logC.i("Google User Email: ${googleUser.email}"); + logC.i("Google User Display Name: ${googleUser.displayName}"); final GoogleSignInAuthentication googleAuth = await googleUser.authentication; - String idToken = googleAuth.idToken ?? ""; + + logC.i("Google Access Token: ${googleAuth.accessToken}"); + logC.i("Google ID Token: ${googleAuth.idToken}"); + + if (googleAuth.idToken == null || googleAuth.idToken!.isEmpty) { + Get.snackbar("Error", "Google sign-in failed. No token received."); + return; + } var response = await http.post( - Uri.parse(APIEndpoint.baseUrl + APIEndpoint.loginGoogle), - body: jsonEncode({"token": idToken}), + Uri.parse("${APIEndpoint.baseUrl}${APIEndpoint.loginGoogle}"), + body: jsonEncode({"token": googleAuth.idToken}), headers: {"Content-Type": "application/json"}, ); if (response.statusCode == 200) { var data = jsonDecode(response.body); - String token = data['token']; // Simpan token untuk sesi login + String token = data['token']; + Get.snackbar("Success", "Google login successful!"); + logC.i("Google Login Token: $token"); } else { - Get.snackbar("Error", "Google login failed"); + var errorMsg = jsonDecode(response.body)['message'] ?? "Google login failed"; + Get.snackbar("Error", errorMsg); } - } catch (e) { + } catch (e, stackTrace) { + logC.e("Google Sign-In Error: $e", stackTrace: stackTrace); Get.snackbar("Error", "Google sign-in error"); } } + + /// **🔹 Logout Function** + Future logout() async { + try { + await _googleSignIn.signOut(); + // await _secureStorage.delete(key: "auth_token"); + + emailController.clear(); + passwordController.clear(); + + Get.snackbar("Success", "Logged out successfully"); + } catch (e) { + logC.e("Logout error: $e"); + Get.snackbar("Error", "Logout failed"); + } + } } diff --git a/lib/feature/login/presentation/login_page.dart b/lib/feature/login/presentation/login_page.dart index cdcd2d0..3dc263c 100644 --- a/lib/feature/login/presentation/login_page.dart +++ b/lib/feature/login/presentation/login_page.dart @@ -48,7 +48,6 @@ class LoginView extends GetView { onPressed: () => controller.loginWithEmail(), child: const Text("Login with Email"), ), - const SizedBox(height: 10), // Google Sign-In Button @@ -65,6 +64,11 @@ class LoginView extends GetView { elevation: 2, ), ), + + ElevatedButton( + onPressed: () => controller.logout(), + child: const Text("log out"), + ), ], ), ), diff --git a/pubspec.lock b/pubspec.lock index e3bb302..75761a7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -62,6 +62,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_dotenv: + dependency: "direct main" + description: + name: flutter_dotenv + sha256: b7c7be5cd9f6ef7a78429cabd2774d3c4af50e79cb2b7593e3d5d763ef95c61b + url: "https://pub.dev" + source: hosted + version: "5.2.1" flutter_lints: dependency: "direct dev" description: diff --git a/pubspec.yaml b/pubspec.yaml index 45f96f4..a723236 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,7 @@ dependencies: google_sign_in: ^6.2.2 http: ^1.3.0 + flutter_dotenv: ^5.2.1 dev_dependencies: flutter_test: From 2763575e1b30552e78ad1beab9c9454eef05d04e Mon Sep 17 00:00:00 2001 From: akhdanre Date: Wed, 12 Mar 2025 14:47:23 +0700 Subject: [PATCH 005/104] fix: token id null --- android/app/src/main/res/values/strings.xml | 5 +++++ .../login/controllers/login_controller.dart | 19 +++++++++++-------- 2 files changed, 16 insertions(+), 8 deletions(-) create mode 100644 android/app/src/main/res/values/strings.xml diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..50b360e --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + + 730226042143-mv9dlpk9cesirgjh2o0f9hvsk0ks8r2f.apps.googleusercontent.com + \ No newline at end of file diff --git a/lib/feature/login/controllers/login_controller.dart b/lib/feature/login/controllers/login_controller.dart index 9fc790c..225a490 100644 --- a/lib/feature/login/controllers/login_controller.dart +++ b/lib/feature/login/controllers/login_controller.dart @@ -12,7 +12,9 @@ class LoginController extends GetxController { var isPasswordHidden = true.obs; var isLoading = false.obs; // Loading state for UI - final GoogleSignIn _googleSignIn = GoogleSignIn(); // Singleton instance + final GoogleSignIn _googleSignIn = GoogleSignIn( + scopes: ['email', 'profile', 'openid'], + ); // Singleton instance void togglePasswordVisibility() { isPasswordHidden.value = !isPasswordHidden.value; @@ -71,26 +73,27 @@ class LoginController extends GetxController { final GoogleSignInAuthentication googleAuth = await googleUser.authentication; - logC.i("Google Access Token: ${googleAuth.accessToken}"); - logC.i("Google ID Token: ${googleAuth.idToken}"); - if (googleAuth.idToken == null || googleAuth.idToken!.isEmpty) { - Get.snackbar("Error", "Google sign-in failed. No token received."); + Get.snackbar("Error", "Google sign-in failed. No ID Token received."); return; } + String idToken = googleAuth.idToken!; + logC.i("Google ID Token: $idToken"); + + // Send ID Token to backend var response = await http.post( Uri.parse("${APIEndpoint.baseUrl}${APIEndpoint.loginGoogle}"), - body: jsonEncode({"token": googleAuth.idToken}), + body: jsonEncode({"id_token": idToken}), // Ensure correct key headers: {"Content-Type": "application/json"}, ); if (response.statusCode == 200) { var data = jsonDecode(response.body); - String token = data['token']; + String backendToken = data['token']; // Token received from your backend Get.snackbar("Success", "Google login successful!"); - logC.i("Google Login Token: $token"); + logC.i("Backend Auth Token: $backendToken"); } else { var errorMsg = jsonDecode(response.body)['message'] ?? "Google login failed"; Get.snackbar("Error", errorMsg); From 32404aceae863488a244f8c7073e3f87e2a12a0e Mon Sep 17 00:00:00 2001 From: akhdanre Date: Tue, 22 Apr 2025 13:38:37 +0700 Subject: [PATCH 006/104] feat: login page done --- lib/component/global_button.dart | 27 ++++++ lib/component/global_text_field.dart | 40 +++++++- lib/component/label_text_field.dart | 27 ++++++ .../login/controllers/login_controller.dart | 14 +-- .../presentation/component/google_button.dart | 29 ++++++ .../component/register_text_button.dart | 33 +++++++ .../login/presentation/login_page.dart | 92 ++++++++----------- 7 files changed, 198 insertions(+), 64 deletions(-) create mode 100644 lib/component/global_button.dart create mode 100644 lib/component/label_text_field.dart create mode 100644 lib/feature/login/presentation/component/google_button.dart create mode 100644 lib/feature/login/presentation/component/register_text_button.dart diff --git a/lib/component/global_button.dart b/lib/component/global_button.dart new file mode 100644 index 0000000..3bd07c5 --- /dev/null +++ b/lib/component/global_button.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +class GlobalButton extends StatelessWidget { + final VoidCallback onPressed; + final String text; + + const GlobalButton({super.key, required this.onPressed, required this.text}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + foregroundColor: Colors.black, + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + padding: const EdgeInsets.symmetric(vertical: 16), + ), + onPressed: onPressed, + child: Text(text), + ), + ); + } +} diff --git a/lib/component/global_text_field.dart b/lib/component/global_text_field.dart index 5529475..80fff87 100644 --- a/lib/component/global_text_field.dart +++ b/lib/component/global_text_field.dart @@ -3,15 +3,49 @@ import 'package:flutter/material.dart'; class GlobalTextField extends StatelessWidget { final TextEditingController controller; final String? hintText; + final String? labelText; + final bool isPassword; + final bool obscureText; + final VoidCallback? onToggleVisibility; - const GlobalTextField({super.key, required this.controller, this.hintText}); + const GlobalTextField({ + super.key, + required this.controller, + this.hintText, + this.labelText, + this.isPassword = false, + this.obscureText = false, + this.onToggleVisibility, + }); @override Widget build(BuildContext context) { return TextField( + controller: controller, + obscureText: isPassword ? obscureText : false, decoration: InputDecoration( - border: OutlineInputBorder(), - labelText: hintText, + labelText: labelText, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + hintText: hintText, + filled: true, + fillColor: const Color.fromARGB(255, 238, 238, 238), + suffixIcon: isPassword + ? IconButton( + icon: Icon(obscureText ? Icons.visibility_off : Icons.visibility), + onPressed: onToggleVisibility, + ) + : null, ), ); } diff --git a/lib/component/label_text_field.dart b/lib/component/label_text_field.dart new file mode 100644 index 0000000..23cd9cc --- /dev/null +++ b/lib/component/label_text_field.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +class LabelTextField extends StatelessWidget { + final String label; + final double fontSize; + final FontWeight fontWeight; + final Alignment alignment; + const LabelTextField( + {super.key, required, required this.label, this.fontSize = 16, this.alignment = Alignment.centerLeft, this.fontWeight = FontWeight.bold}); + + @override + Widget build(BuildContext context) { + return Align( + alignment: alignment, + child: Padding( + padding: EdgeInsets.fromLTRB(10, 5, 0, 5), + child: Text( + label, + style: TextStyle( + fontSize: fontSize, + fontWeight: fontWeight, + ), + ), + ), + ); + } +} diff --git a/lib/feature/login/controllers/login_controller.dart b/lib/feature/login/controllers/login_controller.dart index 225a490..fb8cc20 100644 --- a/lib/feature/login/controllers/login_controller.dart +++ b/lib/feature/login/controllers/login_controller.dart @@ -41,14 +41,9 @@ class LoginController extends GetxController { if (response.statusCode == 200) { var data = jsonDecode(response.body); - String token = data['token']; - - // await _secureStorage.write(key: "auth_token", value: token); - - Get.snackbar("Success", "Login successful!"); - logC.i("Login Token: $token"); } else { var errorMsg = jsonDecode(response.body)['message'] ?? "Invalid credentials"; + logC.i(errorMsg); Get.snackbar("Error", errorMsg); } } catch (e, stackTrace) { @@ -84,19 +79,20 @@ class LoginController extends GetxController { // Send ID Token to backend var response = await http.post( Uri.parse("${APIEndpoint.baseUrl}${APIEndpoint.loginGoogle}"), - body: jsonEncode({"id_token": idToken}), // Ensure correct key + body: jsonEncode({"token_id": idToken}), // Ensure correct key headers: {"Content-Type": "application/json"}, ); if (response.statusCode == 200) { var data = jsonDecode(response.body); - String backendToken = data['token']; // Token received from your backend + Get.snackbar("Success", "Google login successful!"); - logC.i("Backend Auth Token: $backendToken"); + // logC.i("Backend Auth Token: $backendToken"); } else { var errorMsg = jsonDecode(response.body)['message'] ?? "Google login failed"; Get.snackbar("Error", errorMsg); + logC.i(errorMsg); } } catch (e, stackTrace) { logC.e("Google Sign-In Error: $e", stackTrace: stackTrace); diff --git a/lib/feature/login/presentation/component/google_button.dart b/lib/feature/login/presentation/component/google_button.dart new file mode 100644 index 0000000..032c5e1 --- /dev/null +++ b/lib/feature/login/presentation/component/google_button.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +class GoogleButton extends StatelessWidget { + final VoidCallback onPress; + const GoogleButton({super.key, required this.onPress}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: onPress, + icon: Image.asset( + 'assets/logo/google_logo.png', + height: 24, + ), + label: const Text("Masuk dengan Google"), + style: ElevatedButton.styleFrom( + foregroundColor: Colors.black, + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + ); + } +} diff --git a/lib/feature/login/presentation/component/register_text_button.dart b/lib/feature/login/presentation/component/register_text_button.dart new file mode 100644 index 0000000..4c33be2 --- /dev/null +++ b/lib/feature/login/presentation/component/register_text_button.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +class RegisterTextButton extends StatelessWidget { + final VoidCallback? onTap; + const RegisterTextButton({super.key, this.onTap}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + "Belum punya akun? ", + style: TextStyle( + fontSize: 14, + color: Colors.black, + ), + ), + GestureDetector( + onTap: onTap, + child: const Text( + "Daftar", + style: TextStyle( + fontSize: 14, + color: Color.fromARGB(255, 0, 122, 255), + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ); + } +} diff --git a/lib/feature/login/presentation/login_page.dart b/lib/feature/login/presentation/login_page.dart index 3dc263c..7cc58c0 100644 --- a/lib/feature/login/presentation/login_page.dart +++ b/lib/feature/login/presentation/login_page.dart @@ -1,6 +1,11 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/component/global_button.dart'; +import 'package:quiz_app/component/global_text_field.dart'; +import 'package:quiz_app/component/label_text_field.dart'; import 'package:quiz_app/feature/login/controllers/login_controller.dart'; +import 'package:quiz_app/feature/login/presentation/component/google_button.dart'; +import 'package:quiz_app/feature/login/presentation/component/register_text_button.dart'; class LoginView extends GetView { const LoginView({super.key}); @@ -11,64 +16,47 @@ class LoginView extends GetView { body: SafeArea( child: Padding( padding: const EdgeInsets.all(16.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, + child: ListView( children: [ - const Text("Login Page", style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), - const SizedBox(height: 20), - - // Email Input - TextField( + Padding( + padding: const EdgeInsets.symmetric(vertical: 40), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Text("GEN", style: TextStyle(fontSize: 36, fontWeight: FontWeight.bold)), + Text("SO", style: TextStyle(fontSize: 36, fontWeight: FontWeight.bold, color: Colors.red)), + ], + ), + ), + LabelTextField( + label: "Log In", + fontSize: 24, + ), + const SizedBox(height: 10), + LabelTextField(label: "Email"), + GlobalTextField( controller: controller.emailController, - decoration: InputDecoration( - labelText: "Email", - border: OutlineInputBorder(), - ), ), const SizedBox(height: 10), - - // Password Input dengan fitur show/hide - Obx(() => TextField( - controller: controller.passwordController, - obscureText: controller.isPasswordHidden.value, - decoration: InputDecoration( - labelText: "Password", - border: OutlineInputBorder(), - suffixIcon: IconButton( - icon: Icon(controller.isPasswordHidden.value ? Icons.visibility_off : Icons.visibility), - onPressed: controller.togglePasswordVisibility, - ), - ), - )), + LabelTextField(label: "Password"), + Obx( + () => GlobalTextField( + controller: controller.passwordController, + isPassword: true, + obscureText: controller.isPasswordHidden.value, + onToggleVisibility: controller.togglePasswordVisibility, + ), + ), + const SizedBox(height: 40), + GlobalButton(onPressed: controller.loginWithEmail, text: "Masuk"), const SizedBox(height: 20), - - // Login Button - ElevatedButton( - onPressed: () => controller.loginWithEmail(), - child: const Text("Login with Email"), - ), - const SizedBox(height: 10), - - // Google Sign-In Button - ElevatedButton.icon( - onPressed: () => controller.loginWithGoogle(), - icon: Image.asset( - 'assets/logo/google_logo.png', // Pastikan logo Google ada di folder assets - height: 24, - ), - label: const Text("Login with Google"), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, - elevation: 2, - ), - ), - - ElevatedButton( - onPressed: () => controller.logout(), - child: const Text("log out"), + LabelTextField(label: "OR", alignment: Alignment.center), + const SizedBox(height: 20), + GoogleButton( + onPress: controller.loginWithGoogle, ), + const SizedBox(height: 20), + RegisterTextButton() ], ), ), From f479acac91f23b7d812609cfa14912f3ec2a7fad Mon Sep 17 00:00:00 2001 From: akhdanre Date: Tue, 22 Apr 2025 15:08:02 +0700 Subject: [PATCH 007/104] feat: register feature --- lib/app/app.dart | 2 + lib/app/bindings/initial_bindings.dart | 9 ++ lib/app/routes/app_pages.dart | 9 +- lib/component/app_name.dart | 16 ++++ lib/core/endpoint/api_endpoint.dart | 2 + .../models/register/register_request.dart | 25 ++++++ .../models/register/register_response.dart | 0 lib/data/providers/dio_client.dart | 21 +++++ lib/data/services/auth_service.dart | 23 +++++ .../login/controllers/login_controller.dart | 32 +++---- .../component/google_button.dart | 0 .../component/register_text_button.dart | 4 +- .../{presentation => view}/login_page.dart | 18 ++-- .../register/binding/register_binding.dart | 11 +++ .../controller/register_controller.dart | 86 +++++++++++++++++++ lib/feature/register/view/register_page.dart | 70 +++++++++++++++ .../presentation/splash_screen_page.dart | 8 +- lib/global_controller.dart | 12 --- pubspec.lock | 16 ++++ pubspec.yaml | 2 +- 20 files changed, 316 insertions(+), 50 deletions(-) create mode 100644 lib/app/bindings/initial_bindings.dart create mode 100644 lib/component/app_name.dart create mode 100644 lib/data/models/register/register_request.dart create mode 100644 lib/data/models/register/register_response.dart create mode 100644 lib/data/providers/dio_client.dart create mode 100644 lib/data/services/auth_service.dart rename lib/feature/login/{presentation => view}/component/google_button.dart (100%) rename lib/feature/login/{presentation => view}/component/register_text_button.dart (88%) rename lib/feature/login/{presentation => view}/login_page.dart (72%) create mode 100644 lib/feature/register/binding/register_binding.dart create mode 100644 lib/feature/register/controller/register_controller.dart create mode 100644 lib/feature/register/view/register_page.dart delete mode 100644 lib/global_controller.dart diff --git a/lib/app/app.dart b/lib/app/app.dart index ca51c17..9bcc073 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get_navigation/src/root/get_material_app.dart'; +import 'package:quiz_app/app/bindings/initial_bindings.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; class MyApp extends StatelessWidget { @@ -10,6 +11,7 @@ class MyApp extends StatelessWidget { return GetMaterialApp( debugShowCheckedModeBanner: false, title: 'Quiz App', + initialBinding: InitialBindings(), initialRoute: AppRoutes.splashScreen, getPages: AppPages.routes, ); diff --git a/lib/app/bindings/initial_bindings.dart b/lib/app/bindings/initial_bindings.dart new file mode 100644 index 0000000..ea48a0b --- /dev/null +++ b/lib/app/bindings/initial_bindings.dart @@ -0,0 +1,9 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/data/providers/dio_client.dart'; + +class InitialBindings extends Bindings { + @override + void dependencies() { + Get.putAsync(() => ApiClient().init()); + } +} diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index aaa8fd8..d43773c 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -2,7 +2,9 @@ import 'package:get/get_navigation/src/routes/get_route.dart'; import 'package:quiz_app/app/middleware/auth_middleware.dart'; import 'package:quiz_app/feature/home/presentation/home_page.dart'; import 'package:quiz_app/feature/login/bindings/login_binding.dart'; -import 'package:quiz_app/feature/login/presentation/login_page.dart'; +import 'package:quiz_app/feature/login/view/login_page.dart'; +import 'package:quiz_app/feature/register/binding/register_binding.dart'; +import 'package:quiz_app/feature/register/view/register_page.dart'; import 'package:quiz_app/feature/splash_screen/presentation/splash_screen_page.dart'; part 'app_routes.dart'; @@ -18,6 +20,11 @@ class AppPages { page: () => LoginView(), binding: LoginBinding(), ), + GetPage( + name: AppRoutes.registerPage, + page: () => RegisterView(), + binding: RegisterBinding(), + ), GetPage( name: AppRoutes.homePage, page: () => HomeView(), diff --git a/lib/component/app_name.dart b/lib/component/app_name.dart new file mode 100644 index 0000000..b0a2205 --- /dev/null +++ b/lib/component/app_name.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +class AppName extends StatelessWidget { + const AppName({super.key}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Text("GEN", style: TextStyle(fontSize: 36, fontWeight: FontWeight.bold)), + Text("SO", style: TextStyle(fontSize: 36, fontWeight: FontWeight.bold, color: Colors.red)), + ], + ); + } +} diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart index e75ad03..7f975a4 100644 --- a/lib/core/endpoint/api_endpoint.dart +++ b/lib/core/endpoint/api_endpoint.dart @@ -3,4 +3,6 @@ class APIEndpoint { static const String login = "/login"; static const String loginGoogle = "/login/google"; + + static const String register = "/register"; } diff --git a/lib/data/models/register/register_request.dart b/lib/data/models/register/register_request.dart new file mode 100644 index 0000000..8059bcc --- /dev/null +++ b/lib/data/models/register/register_request.dart @@ -0,0 +1,25 @@ +class RegisterRequestModel { + final String email; + final String password; + final String name; + final String birthDate; + final String? phone; + + RegisterRequestModel({ + required this.email, + required this.password, + required this.name, + required this.birthDate, + this.phone, + }); + + Map toJson() { + return { + 'email': email, + 'password': password, + 'name': name, + 'birth_date': birthDate, + if (phone != null) 'phone': phone, + }; + } +} diff --git a/lib/data/models/register/register_response.dart b/lib/data/models/register/register_response.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/data/providers/dio_client.dart b/lib/data/providers/dio_client.dart new file mode 100644 index 0000000..c92b958 --- /dev/null +++ b/lib/data/providers/dio_client.dart @@ -0,0 +1,21 @@ +import 'package:dio/dio.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/core/endpoint/api_endpoint.dart'; + +class ApiClient extends GetxService { + late final Dio dio; + + Future init() async { + dio = Dio(BaseOptions( + baseUrl: APIEndpoint.baseUrl, + connectTimeout: const Duration(minutes: 3), + receiveTimeout: const Duration(minutes: 10), + headers: { + "Content-Type": "application/json", + }, + )); + + dio.interceptors.add(LogInterceptor(responseBody: true)); + return this; + } +} diff --git a/lib/data/services/auth_service.dart b/lib/data/services/auth_service.dart new file mode 100644 index 0000000..98163d6 --- /dev/null +++ b/lib/data/services/auth_service.dart @@ -0,0 +1,23 @@ +import 'package:dio/dio.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/core/endpoint/api_endpoint.dart'; +import 'package:quiz_app/data/models/register/register_request.dart'; +import 'package:quiz_app/data/providers/dio_client.dart'; + +class AuthService extends GetxService { + late final Dio _dio; + + @override + void onInit() { + _dio = Get.find().dio; + super.onInit(); + } + + Future register(RegisterRequestModel request) async { + var data = await _dio.post( + APIEndpoint.register, + data: request.toJson(), + ); + print(data); + } +} diff --git a/lib/feature/login/controllers/login_controller.dart b/lib/feature/login/controllers/login_controller.dart index fb8cc20..b22e887 100644 --- a/lib/feature/login/controllers/login_controller.dart +++ b/lib/feature/login/controllers/login_controller.dart @@ -3,6 +3,7 @@ import 'package:get/get.dart'; import 'package:http/http.dart' as http; import 'package:google_sign_in/google_sign_in.dart'; import 'package:flutter/material.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/core/endpoint/api_endpoint.dart'; import 'package:quiz_app/core/utils/logger.dart'; @@ -79,14 +80,13 @@ class LoginController extends GetxController { // Send ID Token to backend var response = await http.post( Uri.parse("${APIEndpoint.baseUrl}${APIEndpoint.loginGoogle}"), - body: jsonEncode({"token_id": idToken}), // Ensure correct key + body: jsonEncode({"token_id": idToken}), headers: {"Content-Type": "application/json"}, ); if (response.statusCode == 200) { var data = jsonDecode(response.body); - Get.snackbar("Success", "Google login successful!"); // logC.i("Backend Auth Token: $backendToken"); } else { @@ -100,19 +100,21 @@ class LoginController extends GetxController { } } - /// **🔹 Logout Function** - Future logout() async { - try { - await _googleSignIn.signOut(); - // await _secureStorage.delete(key: "auth_token"); + void goToRegsPage() => Get.toNamed(AppRoutes.registerPage); - emailController.clear(); - passwordController.clear(); + // /// **🔹 Logout Function** + // Future logout() async { + // try { + // await _googleSignIn.signOut(); + // // await _secureStorage.delete(key: "auth_token"); - Get.snackbar("Success", "Logged out successfully"); - } catch (e) { - logC.e("Logout error: $e"); - Get.snackbar("Error", "Logout failed"); - } - } + // emailController.clear(); + // passwordController.clear(); + + // Get.snackbar("Success", "Logged out successfully"); + // } catch (e) { + // logC.e("Logout error: $e"); + // Get.snackbar("Error", "Logout failed"); + // } + // } } diff --git a/lib/feature/login/presentation/component/google_button.dart b/lib/feature/login/view/component/google_button.dart similarity index 100% rename from lib/feature/login/presentation/component/google_button.dart rename to lib/feature/login/view/component/google_button.dart diff --git a/lib/feature/login/presentation/component/register_text_button.dart b/lib/feature/login/view/component/register_text_button.dart similarity index 88% rename from lib/feature/login/presentation/component/register_text_button.dart rename to lib/feature/login/view/component/register_text_button.dart index 4c33be2..3a36284 100644 --- a/lib/feature/login/presentation/component/register_text_button.dart +++ b/lib/feature/login/view/component/register_text_button.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; class RegisterTextButton extends StatelessWidget { - final VoidCallback? onTap; - const RegisterTextButton({super.key, this.onTap}); + final VoidCallback onTap; + const RegisterTextButton({super.key, required this.onTap}); @override Widget build(BuildContext context) { diff --git a/lib/feature/login/presentation/login_page.dart b/lib/feature/login/view/login_page.dart similarity index 72% rename from lib/feature/login/presentation/login_page.dart rename to lib/feature/login/view/login_page.dart index 7cc58c0..6718e6f 100644 --- a/lib/feature/login/presentation/login_page.dart +++ b/lib/feature/login/view/login_page.dart @@ -1,11 +1,12 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/component/app_name.dart'; import 'package:quiz_app/component/global_button.dart'; import 'package:quiz_app/component/global_text_field.dart'; import 'package:quiz_app/component/label_text_field.dart'; import 'package:quiz_app/feature/login/controllers/login_controller.dart'; -import 'package:quiz_app/feature/login/presentation/component/google_button.dart'; -import 'package:quiz_app/feature/login/presentation/component/register_text_button.dart'; +import 'package:quiz_app/feature/login/view/component/google_button.dart'; +import 'package:quiz_app/feature/login/view/component/register_text_button.dart'; class LoginView extends GetView { const LoginView({super.key}); @@ -18,16 +19,7 @@ class LoginView extends GetView { padding: const EdgeInsets.all(16.0), child: ListView( children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 40), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: const [ - Text("GEN", style: TextStyle(fontSize: 36, fontWeight: FontWeight.bold)), - Text("SO", style: TextStyle(fontSize: 36, fontWeight: FontWeight.bold, color: Colors.red)), - ], - ), - ), + Padding(padding: const EdgeInsets.symmetric(vertical: 40), child: AppName()), LabelTextField( label: "Log In", fontSize: 24, @@ -56,7 +48,7 @@ class LoginView extends GetView { onPress: controller.loginWithGoogle, ), const SizedBox(height: 20), - RegisterTextButton() + RegisterTextButton(onTap: controller.goToRegsPage) ], ), ), diff --git a/lib/feature/register/binding/register_binding.dart b/lib/feature/register/binding/register_binding.dart new file mode 100644 index 0000000..f00bd23 --- /dev/null +++ b/lib/feature/register/binding/register_binding.dart @@ -0,0 +1,11 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/data/services/auth_service.dart'; +import 'package:quiz_app/feature/register/controller/register_controller.dart'; + +class RegisterBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => AuthService()); + Get.lazyPut(() => RegisterController(Get.find())); + } +} diff --git a/lib/feature/register/controller/register_controller.dart b/lib/feature/register/controller/register_controller.dart new file mode 100644 index 0000000..5428b9d --- /dev/null +++ b/lib/feature/register/controller/register_controller.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/data/models/register/register_request.dart'; +import 'package:quiz_app/data/services/auth_service.dart'; + +class RegisterController extends GetxController { + final AuthService _authService; + + RegisterController(this._authService); + + final TextEditingController nameController = TextEditingController(); + final TextEditingController bDateController = TextEditingController(); + final TextEditingController emailController = TextEditingController(); + final TextEditingController passwordController = TextEditingController(); + final TextEditingController confirmPasswordController = TextEditingController(); + final TextEditingController phoneController = TextEditingController(); + + var isPasswordHidden = true.obs; + var isConfirmPasswordHidden = true.obs; + + void togglePasswordVisibility() { + isPasswordHidden.value = !isPasswordHidden.value; + } + + void toggleConfirmPasswordVisibility() { + isConfirmPasswordHidden.value = !isConfirmPasswordHidden.value; + } + + Future onRegister() async { + String email = emailController.text.trim(); + String name = nameController.text.trim(); + String birthDate = bDateController.text.trim(); + String password = passwordController.text.trim(); + String confirmPassword = confirmPasswordController.text.trim(); + String phone = phoneController.text.trim(); + + if (email.isEmpty || password.isEmpty || confirmPassword.isEmpty || name.isEmpty || birthDate.isEmpty) { + Get.snackbar("Error", "All fields are required"); + return; + } + + if (!_isValidEmail(email)) { + Get.snackbar("Error", "Invalid email format"); + return; + } + + if (!_isValidDateFormat(birthDate)) { + Get.snackbar("Error", "Invalid date format. Use dd-mm-yyyy"); + return; + } + if (password != confirmPassword) { + Get.snackbar("Error", "Passwords do not match"); + return; + } + + if (phone.isNotEmpty && (phone.length < 10 || phone.length > 13)) { + Get.snackbar("Error", "Phone number must be between 10 and 13 digits"); + return; + } + + try { + await _authService.register( + RegisterRequestModel( + email: email, + password: password, + name: name, + birthDate: birthDate, + phone: phone, + ), + ); + Get.back(); + } catch (e) { + Get.snackbar("Error", "Failed to register: ${e.toString()}"); + } + } + + bool _isValidDateFormat(String date) { + final regex = RegExp(r'^([0-2][0-9]|(3)[0-1])\-((0[1-9])|(1[0-2]))\-\d{4}$'); + return regex.hasMatch(date); + } + + bool _isValidEmail(String email) { + final regex = RegExp(r"^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$"); + return regex.hasMatch(email); + } +} diff --git a/lib/feature/register/view/register_page.dart b/lib/feature/register/view/register_page.dart new file mode 100644 index 0000000..5e9621f --- /dev/null +++ b/lib/feature/register/view/register_page.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/component/app_name.dart'; +import 'package:quiz_app/component/global_button.dart'; +import 'package:quiz_app/component/global_text_field.dart'; +import 'package:quiz_app/component/label_text_field.dart'; +import 'package:quiz_app/feature/register/controller/register_controller.dart'; + +class RegisterView extends GetView { + const RegisterView({super.key}); + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: ListView( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 40), + child: AppName(), + ), + LabelTextField(label: "Register", fontSize: 24), + const SizedBox(height: 10), + LabelTextField(label: "Full Name"), + GlobalTextField(controller: controller.nameController), + const SizedBox(height: 10), + LabelTextField(label: "Email"), + GlobalTextField(controller: controller.emailController), + const SizedBox(height: 10), + LabelTextField(label: "Birth Date"), + GlobalTextField( + controller: controller.bDateController, + hintText: "12-08-2001", + ), + LabelTextField(label: "Nomer Telepon (Opsional)"), + GlobalTextField( + controller: controller.phoneController, + hintText: "085708570857", + ), + const SizedBox(height: 10), + LabelTextField(label: "Password"), + Obx( + () => GlobalTextField( + controller: controller.passwordController, + isPassword: true, + obscureText: controller.isPasswordHidden.value, + onToggleVisibility: controller.togglePasswordVisibility), + ), + const SizedBox(height: 10), + LabelTextField(label: "Verify Password"), + Obx( + () => GlobalTextField( + controller: controller.confirmPasswordController, + isPassword: true, + obscureText: controller.isConfirmPasswordHidden.value, + onToggleVisibility: controller.toggleConfirmPasswordVisibility), + ), + const SizedBox(height: 40), + GlobalButton( + onPressed: controller.onRegister, + text: "Register", + ) + ], + ), + ), + ), + ); + } +} diff --git a/lib/feature/splash_screen/presentation/splash_screen_page.dart b/lib/feature/splash_screen/presentation/splash_screen_page.dart index ad2c40e..5819aef 100644 --- a/lib/feature/splash_screen/presentation/splash_screen_page.dart +++ b/lib/feature/splash_screen/presentation/splash_screen_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/component/app_name.dart'; class SplashScreenView extends StatelessWidget { const SplashScreenView({super.key}); @@ -15,12 +16,7 @@ class SplashScreenView extends StatelessWidget { }); return Scaffold( - body: Center( - child: Text( - "Splash Screen", - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), - ), + body: Center(child: AppName()), ); } } diff --git a/lib/global_controller.dart b/lib/global_controller.dart deleted file mode 100644 index 955214f..0000000 --- a/lib/global_controller.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:get/get.dart'; -import 'package:quiz_app/core/utils/logger.dart'; - -class GlobalController extends GetxController with WidgetsBindingObserver { - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - super.didChangeAppLifecycleState(state); - logC.i("state $state"); - } -} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 75761a7..b76549f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -49,6 +49,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dio: + dependency: "direct main" + description: + name: dio + sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9" + url: "https://pub.dev" + source: hosted + version: "5.8.0+1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a723236..0b93ba6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,8 +37,8 @@ dependencies: logger: ^2.5.0 google_sign_in: ^6.2.2 - http: ^1.3.0 flutter_dotenv: ^5.2.1 + dio: ^5.8.0+1 dev_dependencies: flutter_test: From 837823f9370c7f6e0ee56322b408f7ba92982991 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Tue, 22 Apr 2025 19:34:03 +0700 Subject: [PATCH 008/104] fix: the login request into dio and fix the model --- lib/app/bindings/initial_bindings.dart | 2 + lib/app/middleware/auth_middleware.dart | 8 +- lib/data/models/base/base_model.dart | 22 ++++ .../models/login/login_request_model.dart | 23 ++++ .../models/login/login_response_model.dart | 55 +++++++++ lib/data/services/auth_service.dart | 43 ++++++- lib/data/services/user_storage_service.dart | 31 +++++ lib/feature/login/bindings/login_binding.dart | 5 +- .../login/controllers/login_controller.dart | 60 ++++----- .../presentation/splash_screen_page.dart | 27 +++- pubspec.lock | 116 +++++++++++++++++- pubspec.yaml | 1 + 12 files changed, 344 insertions(+), 49 deletions(-) create mode 100644 lib/data/models/base/base_model.dart create mode 100644 lib/data/models/login/login_request_model.dart create mode 100644 lib/data/models/login/login_response_model.dart create mode 100644 lib/data/services/user_storage_service.dart diff --git a/lib/app/bindings/initial_bindings.dart b/lib/app/bindings/initial_bindings.dart index ea48a0b..d9631c9 100644 --- a/lib/app/bindings/initial_bindings.dart +++ b/lib/app/bindings/initial_bindings.dart @@ -1,9 +1,11 @@ import 'package:get/get.dart'; import 'package:quiz_app/data/providers/dio_client.dart'; +import 'package:quiz_app/data/services/user_storage_service.dart'; class InitialBindings extends Bindings { @override void dependencies() { + Get.put(UserStorageService()); Get.putAsync(() => ApiClient().init()); } } diff --git a/lib/app/middleware/auth_middleware.dart b/lib/app/middleware/auth_middleware.dart index b486f35..2dcea74 100644 --- a/lib/app/middleware/auth_middleware.dart +++ b/lib/app/middleware/auth_middleware.dart @@ -1,11 +1,15 @@ import 'package:flutter/material.dart'; -import 'package:get/get_navigation/src/routes/route_middleware.dart'; +import 'package:get/get.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/data/services/user_storage_service.dart'; class AuthMiddleware extends GetMiddleware { @override RouteSettings? redirect(String? route) { - if (route != null) return RouteSettings(name: AppRoutes.loginPage); + final UserStorageService _storageService = Get.find(); + if (!_storageService.isLogged) { + return const RouteSettings(name: AppRoutes.loginPage); + } return null; } } diff --git a/lib/data/models/base/base_model.dart b/lib/data/models/base/base_model.dart new file mode 100644 index 0000000..9e1e6ea --- /dev/null +++ b/lib/data/models/base/base_model.dart @@ -0,0 +1,22 @@ +class BaseResponseModel { + final String message; + final T? data; + final dynamic meta; + + BaseResponseModel({ + required this.message, + this.data, + this.meta, + }); + + factory BaseResponseModel.fromJson( + Map json, + T Function(Map) fromJsonT, + ) { + return BaseResponseModel( + message: json['message'], + data: json['data'] != null ? fromJsonT(json['data']) : null, + meta: json['meta'], + ); + } +} diff --git a/lib/data/models/login/login_request_model.dart b/lib/data/models/login/login_request_model.dart new file mode 100644 index 0000000..c54a862 --- /dev/null +++ b/lib/data/models/login/login_request_model.dart @@ -0,0 +1,23 @@ +class LoginRequestModel { + final String email; + final String password; + + LoginRequestModel({ + required this.email, + required this.password, + }); + + factory LoginRequestModel.fromJson(Map json) { + return LoginRequestModel( + email: json['email'] ?? '', + password: json['password'] ?? '', + ); + } + + Map toJson() { + return { + 'email': email, + 'password': password, + }; + } +} diff --git a/lib/data/models/login/login_response_model.dart b/lib/data/models/login/login_response_model.dart new file mode 100644 index 0000000..0e2a110 --- /dev/null +++ b/lib/data/models/login/login_response_model.dart @@ -0,0 +1,55 @@ +class LoginResponseModel { + final String? id; + final String? googleId; + final String email; + final String name; + final DateTime? birthDate; + final String? picUrl; + final String? phone; + final String locale; + // final DateTime? createdAt; + // final DateTime? updatedAt; + + LoginResponseModel({ + this.id, + this.googleId, + required this.email, + required this.name, + this.birthDate, + this.picUrl, + this.phone, + this.locale = "en-US", + // this.createdAt, + // this.updatedAt, + }); + + factory LoginResponseModel.fromJson(Map json) { + return LoginResponseModel( + id: json['_id'], + googleId: json['google_id'], + email: json['email'], + name: json['name'], + birthDate: json['birth_date'] != null ? DateTime.parse(json['birth_date']) : null, + picUrl: json['pic_url'], + phone: json['phone'], + locale: json['locale'] ?? 'en-US', + // createdAt: json['created_at'] != null ? DateTime.parse(json['created_at']) : null, + // updatedAt: json['updated_at'] != null ? DateTime.parse(json['updated_at']) : null, + ); + } + + Map toJson() { + return { + '_id': id, + 'google_id': googleId, + 'email': email, + 'name': name, + 'birth_date': birthDate?.toIso8601String(), + 'pic_url': picUrl, + 'phone': phone, + 'locale': locale, + // 'created_at': createdAt?.toIso8601String(), + // 'updated_at': updatedAt?.toIso8601String(), + }; + } +} diff --git a/lib/data/services/auth_service.dart b/lib/data/services/auth_service.dart index 98163d6..260c8f0 100644 --- a/lib/data/services/auth_service.dart +++ b/lib/data/services/auth_service.dart @@ -1,6 +1,9 @@ import 'package:dio/dio.dart'; import 'package:get/get.dart'; import 'package:quiz_app/core/endpoint/api_endpoint.dart'; +import 'package:quiz_app/data/models/base/base_model.dart'; +import 'package:quiz_app/data/models/login/login_request_model.dart'; +import 'package:quiz_app/data/models/login/login_response_model.dart'; import 'package:quiz_app/data/models/register/register_request.dart'; import 'package:quiz_app/data/providers/dio_client.dart'; @@ -13,11 +16,47 @@ class AuthService extends GetxService { super.onInit(); } - Future register(RegisterRequestModel request) async { + Future register(RegisterRequestModel request) async { var data = await _dio.post( APIEndpoint.register, data: request.toJson(), ); - print(data); + if (data.statusCode == 200) { + return true; + } else { + throw Exception("Registration failed"); + } + } + + Future loginWithEmail(LoginRequestModel request) async { + final data = request.toJson(); + final response = await _dio.post(APIEndpoint.login, data: data); + + if (response.statusCode == 200) { + final baseResponse = BaseResponseModel.fromJson( + response.data, + (json) => LoginResponseModel.fromJson(json), + ); + return baseResponse.data!; + } else { + throw Exception("Login failed"); + } + } + + Future loginWithGoogle(String idToken) async { + final response = await _dio.post( + APIEndpoint.loginGoogle, + data: {"token_id": idToken}, + ); + + if (response.statusCode == 200) { + final baseResponse = BaseResponseModel.fromJson( + response.data, + (json) => LoginResponseModel.fromJson(json), + ); + return baseResponse.data!; + } else { + throw Exception("Google login failed"); + } } } diff --git a/lib/data/services/user_storage_service.dart b/lib/data/services/user_storage_service.dart new file mode 100644 index 0000000..71e599e --- /dev/null +++ b/lib/data/services/user_storage_service.dart @@ -0,0 +1,31 @@ +import 'dart:convert'; +import 'package:quiz_app/data/models/login/login_response_model.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class UserStorageService { + static const _userKey = 'user_data'; + bool isLogged = false; + + Future saveUser(LoginResponseModel user) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_userKey, jsonEncode(user.toJson())); + } + + Future loadUser() async { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString(_userKey); + + if (jsonString == null) return null; + return LoginResponseModel.fromJson(jsonDecode(jsonString)); + } + + Future clearUser() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_userKey); + } + + Future isLoggedIn() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.containsKey(_userKey); + } +} diff --git a/lib/feature/login/bindings/login_binding.dart b/lib/feature/login/bindings/login_binding.dart index 8ca14ce..dce6efd 100644 --- a/lib/feature/login/bindings/login_binding.dart +++ b/lib/feature/login/bindings/login_binding.dart @@ -1,10 +1,13 @@ import 'package:get/get_core/get_core.dart'; import 'package:get/get_instance/get_instance.dart'; +import 'package:quiz_app/data/services/auth_service.dart'; +import 'package:quiz_app/data/services/user_storage_service.dart'; import 'package:quiz_app/feature/login/controllers/login_controller.dart'; class LoginBinding extends Bindings { @override void dependencies() { - Get.lazyPut(() => LoginController()); + Get.lazyPut(() => AuthService()); + Get.lazyPut(() => LoginController(Get.find(), Get.find())); } } diff --git a/lib/feature/login/controllers/login_controller.dart b/lib/feature/login/controllers/login_controller.dart index b22e887..dfc4297 100644 --- a/lib/feature/login/controllers/login_controller.dart +++ b/lib/feature/login/controllers/login_controller.dart @@ -1,21 +1,26 @@ -import 'dart:convert'; import 'package:get/get.dart'; -import 'package:http/http.dart' as http; import 'package:google_sign_in/google_sign_in.dart'; import 'package:flutter/material.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; -import 'package:quiz_app/core/endpoint/api_endpoint.dart'; import 'package:quiz_app/core/utils/logger.dart'; +import 'package:quiz_app/data/models/login/login_request_model.dart'; +import 'package:quiz_app/data/models/login/login_response_model.dart'; +import 'package:quiz_app/data/services/auth_service.dart'; +import 'package:quiz_app/data/services/user_storage_service.dart'; class LoginController extends GetxController { + final AuthService _authService; + final UserStorageService _userStorageService; + + LoginController(this._authService, this._userStorageService); final TextEditingController emailController = TextEditingController(); final TextEditingController passwordController = TextEditingController(); var isPasswordHidden = true.obs; - var isLoading = false.obs; // Loading state for UI + var isLoading = false.obs; final GoogleSignIn _googleSignIn = GoogleSignIn( scopes: ['email', 'profile', 'openid'], - ); // Singleton instance + ); void togglePasswordVisibility() { isPasswordHidden.value = !isPasswordHidden.value; @@ -34,19 +39,18 @@ class LoginController extends GetxController { try { isLoading.value = true; - var response = await http.post( - Uri.parse("${APIEndpoint.baseUrl}${APIEndpoint.login}"), - body: jsonEncode({"email": email, "password": password}), - headers: {"Content-Type": "application/json"}, + LoginResponseModel response = await _authService.loginWithEmail( + LoginRequestModel( + email: email, + password: password, + ), ); - if (response.statusCode == 200) { - var data = jsonDecode(response.body); - } else { - var errorMsg = jsonDecode(response.body)['message'] ?? "Invalid credentials"; - logC.i(errorMsg); - Get.snackbar("Error", errorMsg); - } + await _userStorageService.saveUser(response); + + _userStorageService.isLogged = true; + + Get.toNamed(AppRoutes.homePage); } catch (e, stackTrace) { logC.e(e, stackTrace: stackTrace); Get.snackbar("Error", "Failed to connect to server"); @@ -63,10 +67,6 @@ class LoginController extends GetxController { return; } - logC.i("Google User ID: ${googleUser.id}"); - logC.i("Google User Email: ${googleUser.email}"); - logC.i("Google User Display Name: ${googleUser.displayName}"); - final GoogleSignInAuthentication googleAuth = await googleUser.authentication; if (googleAuth.idToken == null || googleAuth.idToken!.isEmpty) { @@ -75,25 +75,13 @@ class LoginController extends GetxController { } String idToken = googleAuth.idToken!; - logC.i("Google ID Token: $idToken"); - // Send ID Token to backend - var response = await http.post( - Uri.parse("${APIEndpoint.baseUrl}${APIEndpoint.loginGoogle}"), - body: jsonEncode({"token_id": idToken}), - headers: {"Content-Type": "application/json"}, - ); + final response = await _authService.loginWithGoogle(idToken); + await _userStorageService.saveUser(response); - if (response.statusCode == 200) { - var data = jsonDecode(response.body); + _userStorageService.isLogged = true; - Get.snackbar("Success", "Google login successful!"); - // logC.i("Backend Auth Token: $backendToken"); - } else { - var errorMsg = jsonDecode(response.body)['message'] ?? "Google login failed"; - Get.snackbar("Error", errorMsg); - logC.i(errorMsg); - } + Get.toNamed(AppRoutes.homePage); } catch (e, stackTrace) { logC.e("Google Sign-In Error: $e", stackTrace: stackTrace); Get.snackbar("Error", "Google sign-in error"); diff --git a/lib/feature/splash_screen/presentation/splash_screen_page.dart b/lib/feature/splash_screen/presentation/splash_screen_page.dart index 5819aef..c5c2dc5 100644 --- a/lib/feature/splash_screen/presentation/splash_screen_page.dart +++ b/lib/feature/splash_screen/presentation/splash_screen_page.dart @@ -2,21 +2,36 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/component/app_name.dart'; +import 'package:quiz_app/data/services/user_storage_service.dart'; class SplashScreenView extends StatelessWidget { const SplashScreenView({super.key}); + void _navigate() async { + final storageService = Get.find(); + final isLoggedIn = await storageService.isLoggedIn(); + storageService.isLogged = isLoggedIn; + + await Future.delayed(const Duration(seconds: 2)); + + if (isLoggedIn) { + Get.offNamed(AppRoutes.homePage); + } else { + Get.offNamed(AppRoutes.loginPage); + } + } + @override Widget build(BuildContext context) { - // Delay navigation after the first frame is rendered + // Jalankan navigasi setelah frame pertama selesai dirender WidgetsBinding.instance.addPostFrameCallback((_) { - Future.delayed(const Duration(seconds: 2), () { - Get.offNamed(AppRoutes.homePage); - }); + _navigate(); }); - return Scaffold( - body: Center(child: AppName()), + return const Scaffold( + body: Center( + child: AppName(), + ), ); } } diff --git a/pubspec.lock b/pubspec.lock index b76549f..299040d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -73,6 +73,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -161,7 +177,7 @@ packages: source: hosted version: "0.12.4+3" http: - dependency: "direct main" + dependency: transitive description: name: http sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f @@ -248,6 +264,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -256,6 +304,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac" + url: "https://pub.dev" + source: hosted + version: "2.4.10" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -341,6 +445,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" sdks: dart: ">=3.6.0 <4.0.0" - flutter: ">=3.24.0" + flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml index 0b93ba6..c731b77 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,7 @@ dependencies: google_sign_in: ^6.2.2 flutter_dotenv: ^5.2.1 dio: ^5.8.0+1 + shared_preferences: ^2.5.3 dev_dependencies: flutter_test: From 9c36507fb7ad2fef717801d940fe4a46291a0842 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sat, 26 Apr 2025 14:02:22 +0700 Subject: [PATCH 009/104] feat: done creating home page and fix the interface --- lib/app/routes/app_pages.dart | 4 +- lib/component/app_name.dart | 28 ++++- lib/component/global_button.dart | 10 +- lib/component/global_text_field.dart | 20 +-- lib/component/label_text_field.dart | 15 ++- lib/component/quiz_container_component.dart | 83 +++++++++++++ lib/core/helper/responsive.dart | 25 ++++ lib/feature/home/binding/home_binding.dart | 10 ++ .../home/controller/home_controller.dart | 26 ++++ lib/feature/home/presentation/home_page.dart | 23 ---- .../home/view/component/button_option.dart | 117 ++++++++++++++++++ .../component/recomendation_component.dart | 36 ++++++ .../home/view/component/search_component.dart | 104 ++++++++++++++++ .../home/view/component/user_gretings.dart | 54 ++++++++ lib/feature/home/view/home_page.dart | 53 ++++++++ lib/feature/login/view/login_page.dart | 56 ++++++--- 16 files changed, 609 insertions(+), 55 deletions(-) create mode 100644 lib/component/quiz_container_component.dart create mode 100644 lib/core/helper/responsive.dart create mode 100644 lib/feature/home/binding/home_binding.dart create mode 100644 lib/feature/home/controller/home_controller.dart delete mode 100644 lib/feature/home/presentation/home_page.dart create mode 100644 lib/feature/home/view/component/button_option.dart create mode 100644 lib/feature/home/view/component/recomendation_component.dart create mode 100644 lib/feature/home/view/component/search_component.dart create mode 100644 lib/feature/home/view/component/user_gretings.dart create mode 100644 lib/feature/home/view/home_page.dart diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index d43773c..7b56701 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -1,6 +1,7 @@ import 'package:get/get_navigation/src/routes/get_route.dart'; import 'package:quiz_app/app/middleware/auth_middleware.dart'; -import 'package:quiz_app/feature/home/presentation/home_page.dart'; +import 'package:quiz_app/feature/home/binding/home_binding.dart'; +import 'package:quiz_app/feature/home/view/home_page.dart'; import 'package:quiz_app/feature/login/bindings/login_binding.dart'; import 'package:quiz_app/feature/login/view/login_page.dart'; import 'package:quiz_app/feature/register/binding/register_binding.dart'; @@ -28,6 +29,7 @@ class AppPages { GetPage( name: AppRoutes.homePage, page: () => HomeView(), + binding: HomeBinding(), middlewares: [AuthMiddleware()], ), ]; diff --git a/lib/component/app_name.dart b/lib/component/app_name.dart index b0a2205..0154c68 100644 --- a/lib/component/app_name.dart +++ b/lib/component/app_name.dart @@ -1,15 +1,35 @@ import 'package:flutter/material.dart'; class AppName extends StatelessWidget { - const AppName({super.key}); + final double fontSize; + + const AppName({super.key, this.fontSize = 36}); @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.center, - children: const [ - Text("GEN", style: TextStyle(fontSize: 36, fontWeight: FontWeight.bold)), - Text("SO", style: TextStyle(fontSize: 36, fontWeight: FontWeight.bold, color: Colors.red)), + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + "GEN", + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.w700, + color: Color(0xFF172B4D), + letterSpacing: 1.2, + ), + ), + Text( + "SO", + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.w700, + color: Color(0xFF0052CC), + letterSpacing: 1.2, + ), + ), ], ); } diff --git a/lib/component/global_button.dart b/lib/component/global_button.dart index 3bd07c5..a7c145c 100644 --- a/lib/component/global_button.dart +++ b/lib/component/global_button.dart @@ -12,12 +12,18 @@ class GlobalButton extends StatelessWidget { width: double.infinity, child: ElevatedButton( style: ElevatedButton.styleFrom( - foregroundColor: Colors.black, - backgroundColor: Colors.white, + foregroundColor: Colors.white, + backgroundColor: const Color(0xFF0052CC), + shadowColor: const Color(0x330052CC), + elevation: 4, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), padding: const EdgeInsets.symmetric(vertical: 16), + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), ), onPressed: onPressed, child: Text(text), diff --git a/lib/component/global_text_field.dart b/lib/component/global_text_field.dart index 80fff87..0827170 100644 --- a/lib/component/global_text_field.dart +++ b/lib/component/global_text_field.dart @@ -25,24 +25,30 @@ class GlobalTextField extends StatelessWidget { obscureText: isPassword ? obscureText : false, decoration: InputDecoration( labelText: labelText, + labelStyle: const TextStyle(color: Color(0xFF6B778C), fontSize: 14), + hintText: hintText, + hintStyle: const TextStyle(color: Color(0xFF6B778C), fontSize: 14), + filled: true, + fillColor: const Color(0xFFFAFBFC), // Background soft white + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), border: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, + borderSide: BorderSide(color: Colors.transparent), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, + borderSide: BorderSide(color: Colors.transparent), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, + borderSide: const BorderSide(color: Color(0xFF0052CC), width: 2), ), - hintText: hintText, - filled: true, - fillColor: const Color.fromARGB(255, 238, 238, 238), suffixIcon: isPassword ? IconButton( - icon: Icon(obscureText ? Icons.visibility_off : Icons.visibility), + icon: Icon( + obscureText ? Icons.visibility_off : Icons.visibility, + color: const Color(0xFF6B778C), + ), onPressed: onToggleVisibility, ) : null, diff --git a/lib/component/label_text_field.dart b/lib/component/label_text_field.dart index 23cd9cc..59f3008 100644 --- a/lib/component/label_text_field.dart +++ b/lib/component/label_text_field.dart @@ -5,20 +5,29 @@ class LabelTextField extends StatelessWidget { final double fontSize; final FontWeight fontWeight; final Alignment alignment; - const LabelTextField( - {super.key, required, required this.label, this.fontSize = 16, this.alignment = Alignment.centerLeft, this.fontWeight = FontWeight.bold}); + final Color? color; + + const LabelTextField({ + super.key, + required this.label, + this.fontSize = 16, + this.fontWeight = FontWeight.bold, + this.alignment = Alignment.centerLeft, + this.color, // Tambahkan warna opsional + }); @override Widget build(BuildContext context) { return Align( alignment: alignment, child: Padding( - padding: EdgeInsets.fromLTRB(10, 5, 0, 5), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), // padding lebih natural child: Text( label, style: TextStyle( fontSize: fontSize, fontWeight: fontWeight, + color: color ?? const Color(0xFF172B4D), // default modern dark text ), ), ), diff --git a/lib/component/quiz_container_component.dart b/lib/component/quiz_container_component.dart new file mode 100644 index 0000000..0a1d448 --- /dev/null +++ b/lib/component/quiz_container_component.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +class QuizContainerComponent extends StatelessWidget { + const QuizContainerComponent({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Color(0xFFFAFBFC), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Color(0xFFE1E4E8), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 8, + offset: Offset(0, 2), + ) + ], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: Color(0xFF0052CC), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.school, color: Colors.white, size: 28), + ), + const SizedBox(width: 12), + // Quiz Info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Physics", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF172B4D), + ), + ), + const SizedBox(height: 4), + const Text( + "created by Akhdan Rabbani", + style: TextStyle( + fontSize: 12, + color: Color(0xFF6B778C), + ), + ), + const SizedBox(height: 8), + Row( + children: const [ + Icon(Icons.format_list_bulleted, size: 14, color: Color(0xFF6B778C)), + SizedBox(width: 4), + Text( + "50 Quizzes", + style: TextStyle(fontSize: 12, color: Color(0xFF6B778C)), + ), + SizedBox(width: 12), + Icon(Icons.access_time, size: 14, color: Color(0xFF6B778C)), + SizedBox(width: 4), + Text( + "1 hr duration", + style: TextStyle(fontSize: 12, color: Color(0xFF6B778C)), + ), + ], + ) + ], + ), + ) + ], + ), + ); + } +} diff --git a/lib/core/helper/responsive.dart b/lib/core/helper/responsive.dart new file mode 100644 index 0000000..007718c --- /dev/null +++ b/lib/core/helper/responsive.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +class SizeConfig { + late double screenWidth; + late double screenHeight; + double baseSize = 8.0; + + SizeConfig(BuildContext context) { + final mediaQueryData = MediaQuery.of(context); + screenWidth = mediaQueryData.size.width; + screenHeight = mediaQueryData.size.height; + } + + double size(double multiplier) { + return baseSize * multiplier; + } + + double height(double multiplier) { + return screenHeight * (multiplier / 100); + } + + double width(double multiplier) { + return screenWidth * (multiplier / 100); + } +} diff --git a/lib/feature/home/binding/home_binding.dart b/lib/feature/home/binding/home_binding.dart new file mode 100644 index 0000000..d2028f7 --- /dev/null +++ b/lib/feature/home/binding/home_binding.dart @@ -0,0 +1,10 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/data/services/user_storage_service.dart'; +import 'package:quiz_app/feature/home/controller/home_controller.dart'; + +class HomeBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => HomeController(Get.find())); + } +} diff --git a/lib/feature/home/controller/home_controller.dart b/lib/feature/home/controller/home_controller.dart new file mode 100644 index 0000000..aef1787 --- /dev/null +++ b/lib/feature/home/controller/home_controller.dart @@ -0,0 +1,26 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/data/models/login/login_response_model.dart'; +import 'package:quiz_app/data/services/user_storage_service.dart'; + +class HomeController extends GetxController { + final UserStorageService _userStorageService; + + HomeController(this._userStorageService); + + Rx userName = "Dani".obs; + String? userImage; + + @override + void onInit() { + getUserData(); + super.onInit(); + } + + Future getUserData() async { + LoginResponseModel? data = await _userStorageService.loadUser(); + if (data == null) return; + print("User data: ${data.toJson()}"); + userName.value = data.name; + userImage = data.picUrl; + } +} diff --git a/lib/feature/home/presentation/home_page.dart b/lib/feature/home/presentation/home_page.dart deleted file mode 100644 index 68bfbfc..0000000 --- a/lib/feature/home/presentation/home_page.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:quiz_app/core/utils/logger.dart'; - -class HomeView extends StatelessWidget with WidgetsBindingObserver { - const HomeView({super.key}); - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - - logC.i("the state is $state"); - super.didChangeAppLifecycleState(state); - } - - - @override - Widget build(BuildContext context) { - return Scaffold( - body: Center( - child: Text("Home screen"), - ), - ); - } -} diff --git a/lib/feature/home/view/component/button_option.dart b/lib/feature/home/view/component/button_option.dart new file mode 100644 index 0000000..fe1647c --- /dev/null +++ b/lib/feature/home/view/component/button_option.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; + +class ButtonOption extends StatelessWidget { + final VoidCallback onCreate; + final VoidCallback onCreateRoom; + final VoidCallback onJoinRoom; + + const ButtonOption({ + super.key, + required this.onCreate, + required this.onCreateRoom, + required this.onJoinRoom, + }); + + @override + Widget build(BuildContext context) { + return Container( + height: 220, + margin: const EdgeInsets.symmetric(vertical: 5, horizontal: 20), + child: Row( + children: [ + Expanded(child: _buildCreateButton()), + const SizedBox(width: 12), + Expanded(child: _buildRoomButtons()), + ], + ), + ); + } + + Widget _buildCreateButton() { + return InkWell( + onTap: onCreate, + borderRadius: BorderRadius.circular(16), + child: SizedBox( + height: double.infinity, + child: _buildButtonContainer( + label: 'Buat Quiz', + gradientColors: [Color(0xFF0052CC), Color(0xFF0367D3)], + icon: Icons.create, + ), + ), + ); + } + + Widget _buildRoomButtons() { + return Column( + children: [ + Expanded( + child: InkWell( + onTap: onCreateRoom, + borderRadius: BorderRadius.circular(16), + child: _buildButtonContainer( + label: 'Buat Room', + gradientColors: [Color(0xFF36B37E), Color(0xFF22C39F)], + icon: Icons.meeting_room, + ), + ), + ), + const SizedBox(height: 12), + Expanded( + child: InkWell( + onTap: onJoinRoom, + borderRadius: BorderRadius.circular(16), + child: _buildButtonContainer( + label: 'Join Room', + gradientColors: [Color(0xFFFFAB00), Color(0xFFFFC107)], + icon: Icons.group, + ), + ), + ), + ], + ); + } + + Widget _buildButtonContainer({ + required String label, + required List gradientColors, + required IconData icon, + }) { + return Container( + alignment: Alignment.bottomLeft, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: gradientColors, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: gradientColors.last.withOpacity(0.4), + blurRadius: 6, + offset: const Offset(2, 4), + ), + ], + ), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 10, vertical: 10), + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + Icon(icon, color: Colors.white, size: 24), + const SizedBox(width: 8), + Text( + label, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/feature/home/view/component/recomendation_component.dart b/lib/feature/home/view/component/recomendation_component.dart new file mode 100644 index 0000000..a0e4db4 --- /dev/null +++ b/lib/feature/home/view/component/recomendation_component.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:quiz_app/component/quiz_container_component.dart'; + +class RecomendationComponent extends StatelessWidget { + const RecomendationComponent({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _label(), + const SizedBox(height: 10), + QuizContainerComponent(), + const SizedBox(height: 10), + QuizContainerComponent(), + const SizedBox(height: 10), + QuizContainerComponent() + ], + ); + } + + Widget _label() { + return const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text( + "Quiz Recommendation", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Color(0xFF172B4D), // dark text + ), + ), + ); + } +} diff --git a/lib/feature/home/view/component/search_component.dart b/lib/feature/home/view/component/search_component.dart new file mode 100644 index 0000000..eef2c2f --- /dev/null +++ b/lib/feature/home/view/component/search_component.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; + +class SearchComponent extends StatelessWidget { + const SearchComponent({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(vertical: 10), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), + decoration: BoxDecoration( + color: const Color(0xFFFAFBFC), // Soft background + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Color(0xFFE1E4E8)), // Light border + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTitleSection(), + const SizedBox(height: 12), + _buildCategoryRow(), + const SizedBox(height: 12), + _buildSearchInput(), + ], + ), + ); + } + + Widget _buildTitleSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Text( + "Ready for a new challenge?", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Color(0xFF172B4D), + ), + ), + SizedBox(height: 5), + Text( + "Search or select by category", + style: TextStyle( + fontSize: 14, + color: Color(0xFF6B778C), // Soft gray text + ), + ), + ], + ); + } + + Widget _buildCategoryRow() { + return Row( + children: [ + _buildCategoryComponent("History"), + const SizedBox(width: 8), + _buildCategoryComponent("Science"), + ], + ); + } + + Widget _buildCategoryComponent(String category) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Color(0xFFD6E4FF), // Soft blue chip + borderRadius: BorderRadius.circular(20), + ), + child: Text( + category, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF0052CC), // Primary blue + ), + ), + ); + } + + Widget _buildSearchInput() { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 6, + offset: Offset(0, 2), + ), + ], + ), + child: const TextField( + decoration: InputDecoration( + hintText: "Search for quizzes...", + hintStyle: TextStyle(color: Color(0xFF6B778C)), + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ), + ); + } +} diff --git a/lib/feature/home/view/component/user_gretings.dart b/lib/feature/home/view/component/user_gretings.dart new file mode 100644 index 0000000..8a3ee05 --- /dev/null +++ b/lib/feature/home/view/component/user_gretings.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; + +class UserGretingsComponent extends StatelessWidget { + final String userName; + final String? userImage; + const UserGretingsComponent({super.key, required this.userName, required this.userImage}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + if (userImage != null) + CircleAvatar( + backgroundImage: NetworkImage(userImage!), + ) + else + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.grey, + ), + child: Icon( + Icons.person, + color: Colors.white, + size: 30, + ), + ), + SizedBox( + width: 10, + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Selamat Siang", + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text( + "Hello $userName", + style: TextStyle(fontWeight: FontWeight.w500), + ), + ], + ), + Spacer(), + Icon(Icons.notifications), + SizedBox( + width: 10, + ) + ], + ); + } +} diff --git a/lib/feature/home/view/home_page.dart b/lib/feature/home/view/home_page.dart new file mode 100644 index 0000000..794116a --- /dev/null +++ b/lib/feature/home/view/home_page.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/feature/home/controller/home_controller.dart'; +import 'package:quiz_app/feature/home/view/component/button_option.dart'; +import 'package:quiz_app/feature/home/view/component/recomendation_component.dart'; +import 'package:quiz_app/feature/home/view/component/search_component.dart'; +import 'package:quiz_app/feature/home/view/component/user_gretings.dart'; + +class HomeView extends GetView { + const HomeView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: ListView( + children: [ + Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + Obx( + () => UserGretingsComponent( + userName: controller.userName.value, + userImage: controller.userImage, + ), + ), + const SizedBox(height: 20), + ], + ), + ), + // ButtonOption di luar Padding + ButtonOption( + onCreate: () {}, + onCreateRoom: () {}, + onJoinRoom: () {}, + ), + Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + SearchComponent(), + const SizedBox(height: 20), + RecomendationComponent(), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/feature/login/view/login_page.dart b/lib/feature/login/view/login_page.dart index 6718e6f..897cdeb 100644 --- a/lib/feature/login/view/login_page.dart +++ b/lib/feature/login/view/login_page.dart @@ -14,41 +14,67 @@ class LoginView extends GetView { @override Widget build(BuildContext context) { return Scaffold( + backgroundColor: const Color(0xFFFAFBFC), // background soft clean body: SafeArea( child: Padding( - padding: const EdgeInsets.all(16.0), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), child: ListView( children: [ - Padding(padding: const EdgeInsets.symmetric(vertical: 40), child: AppName()), - LabelTextField( + const SizedBox(height: 40), + const AppName(), + const SizedBox(height: 40), + const LabelTextField( label: "Log In", - fontSize: 24, + fontSize: 28, + fontWeight: FontWeight.bold, + color: Color(0xFF172B4D), ), - const SizedBox(height: 10), - LabelTextField(label: "Email"), + const SizedBox(height: 24), + const LabelTextField( + label: "Email", + color: Color(0xFF6B778C), + fontSize: 14, + ), + const SizedBox(height: 6), GlobalTextField( controller: controller.emailController, + hintText: "Masukkan email anda", ), - const SizedBox(height: 10), - LabelTextField(label: "Password"), + const SizedBox(height: 20), + const LabelTextField( + label: "Password", + color: Color(0xFF6B778C), + fontSize: 14, + ), + const SizedBox(height: 6), Obx( () => GlobalTextField( controller: controller.passwordController, isPassword: true, obscureText: controller.isPasswordHidden.value, onToggleVisibility: controller.togglePasswordVisibility, + hintText: "Masukkan password anda", ), ), - const SizedBox(height: 40), - GlobalButton(onPressed: controller.loginWithEmail, text: "Masuk"), - const SizedBox(height: 20), - LabelTextField(label: "OR", alignment: Alignment.center), - const SizedBox(height: 20), + const SizedBox(height: 32), + GlobalButton( + onPressed: controller.loginWithEmail, + text: "Masuk", + ), + const SizedBox(height: 24), + const LabelTextField( + label: "OR", + alignment: Alignment.center, + color: Color(0xFF6B778C), + ), + const SizedBox(height: 24), GoogleButton( onPress: controller.loginWithGoogle, ), - const SizedBox(height: 20), - RegisterTextButton(onTap: controller.goToRegsPage) + const SizedBox(height: 32), + RegisterTextButton( + onTap: controller.goToRegsPage, + ), ], ), ), From 404138473301606b328598818d341a9bd3d642a5 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sat, 26 Apr 2025 15:30:18 +0700 Subject: [PATCH 010/104] fix: interface and logic --- lib/component/global_button.dart | 51 +++++++++++++++---- lib/component/global_text_field.dart | 2 +- lib/feature/home/view/home_page.dart | 1 + .../login/controllers/login_controller.dart | 39 ++++++++------ lib/feature/login/view/login_page.dart | 9 ++-- lib/feature/register/view/register_page.dart | 1 + .../presentation/splash_screen_page.dart | 1 + 7 files changed, 73 insertions(+), 31 deletions(-) diff --git a/lib/component/global_button.dart b/lib/component/global_button.dart index a7c145c..0f03d20 100644 --- a/lib/component/global_button.dart +++ b/lib/component/global_button.dart @@ -1,31 +1,62 @@ import 'package:flutter/material.dart'; -class GlobalButton extends StatelessWidget { - final VoidCallback onPressed; - final String text; +enum ButtonType { primary, secondary, disabled } - const GlobalButton({super.key, required this.onPressed, required this.text}); +class GlobalButton extends StatelessWidget { + final VoidCallback? onPressed; + final String text; + final ButtonType type; + + const GlobalButton({ + super.key, + required this.text, + required this.onPressed, + this.type = ButtonType.primary, + }); @override Widget build(BuildContext context) { + final bool isDisabled = type == ButtonType.disabled || onPressed == null; + + Color backgroundColor; + Color foregroundColor; + Color? borderColor; + + switch (type) { + case ButtonType.primary: + backgroundColor = const Color(0xFF0052CC); + foregroundColor = Colors.white; + break; + case ButtonType.secondary: + backgroundColor = Colors.white; + foregroundColor = const Color(0xFF0052CC); + borderColor = const Color(0xFF0052CC); + break; + case ButtonType.disabled: + backgroundColor = const Color(0xFFE0E0E0); + foregroundColor = const Color(0xFF9E9E9E); + break; + } + return SizedBox( width: double.infinity, child: ElevatedButton( style: ElevatedButton.styleFrom( - foregroundColor: Colors.white, - backgroundColor: const Color(0xFF0052CC), - shadowColor: const Color(0x330052CC), - elevation: 4, + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + elevation: isDisabled ? 0 : 4, + shadowColor: !isDisabled ? backgroundColor.withOpacity(0.3) : Colors.transparent, + padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), + side: borderColor != null ? BorderSide(color: borderColor, width: 2) : BorderSide.none, ), - padding: const EdgeInsets.symmetric(vertical: 16), textStyle: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, ), ), - onPressed: onPressed, + onPressed: isDisabled ? null : onPressed, child: Text(text), ), ); diff --git a/lib/component/global_text_field.dart b/lib/component/global_text_field.dart index 0827170..f855ad4 100644 --- a/lib/component/global_text_field.dart +++ b/lib/component/global_text_field.dart @@ -29,7 +29,7 @@ class GlobalTextField extends StatelessWidget { hintText: hintText, hintStyle: const TextStyle(color: Color(0xFF6B778C), fontSize: 14), filled: true, - fillColor: const Color(0xFFFAFBFC), // Background soft white + fillColor: const Color.fromARGB(255, 234, 234, 235), // Background soft white contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), border: OutlineInputBorder( borderRadius: BorderRadius.circular(16), diff --git a/lib/feature/home/view/home_page.dart b/lib/feature/home/view/home_page.dart index 794116a..5a8fd41 100644 --- a/lib/feature/home/view/home_page.dart +++ b/lib/feature/home/view/home_page.dart @@ -12,6 +12,7 @@ class HomeView extends GetView { @override Widget build(BuildContext context) { return Scaffold( + backgroundColor: const Color(0xFFFAFBFC), body: SafeArea( child: ListView( children: [ diff --git a/lib/feature/login/controllers/login_controller.dart b/lib/feature/login/controllers/login_controller.dart index dfc4297..8160f24 100644 --- a/lib/feature/login/controllers/login_controller.dart +++ b/lib/feature/login/controllers/login_controller.dart @@ -2,6 +2,7 @@ import 'package:get/get.dart'; import 'package:google_sign_in/google_sign_in.dart'; import 'package:flutter/material.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/component/global_button.dart'; import 'package:quiz_app/core/utils/logger.dart'; import 'package:quiz_app/data/models/login/login_request_model.dart'; import 'package:quiz_app/data/models/login/login_response_model.dart'; @@ -13,15 +14,37 @@ class LoginController extends GetxController { final UserStorageService _userStorageService; LoginController(this._authService, this._userStorageService); + final TextEditingController emailController = TextEditingController(); final TextEditingController passwordController = TextEditingController(); + final Rx isButtonEnabled = ButtonType.disabled.obs; + var isPasswordHidden = true.obs; var isLoading = false.obs; + final GoogleSignIn _googleSignIn = GoogleSignIn( scopes: ['email', 'profile', 'openid'], ); + @override + void onInit() { + super.onInit(); + emailController.addListener(_validateFields); + passwordController.addListener(_validateFields); + } + + void _validateFields() { + final isEmailNotEmpty = emailController.text.trim().isNotEmpty; + final isPasswordNotEmpty = passwordController.text.trim().isNotEmpty; + print('its type'); + if (isEmailNotEmpty && isPasswordNotEmpty) { + isButtonEnabled.value = ButtonType.primary; + } else { + isButtonEnabled.value = ButtonType.disabled; + } + } + void togglePasswordVisibility() { isPasswordHidden.value = !isPasswordHidden.value; } @@ -89,20 +112,4 @@ class LoginController extends GetxController { } void goToRegsPage() => Get.toNamed(AppRoutes.registerPage); - - // /// **🔹 Logout Function** - // Future logout() async { - // try { - // await _googleSignIn.signOut(); - // // await _secureStorage.delete(key: "auth_token"); - - // emailController.clear(); - // passwordController.clear(); - - // Get.snackbar("Success", "Logged out successfully"); - // } catch (e) { - // logC.e("Logout error: $e"); - // Get.snackbar("Error", "Logout failed"); - // } - // } } diff --git a/lib/feature/login/view/login_page.dart b/lib/feature/login/view/login_page.dart index 897cdeb..9840f42 100644 --- a/lib/feature/login/view/login_page.dart +++ b/lib/feature/login/view/login_page.dart @@ -57,10 +57,11 @@ class LoginView extends GetView { ), ), const SizedBox(height: 32), - GlobalButton( - onPressed: controller.loginWithEmail, - text: "Masuk", - ), + Obx(() => GlobalButton( + onPressed: controller.loginWithEmail, + text: "Masuk", + type: controller.isButtonEnabled.value, + )), const SizedBox(height: 24), const LabelTextField( label: "OR", diff --git a/lib/feature/register/view/register_page.dart b/lib/feature/register/view/register_page.dart index 5e9621f..0063f4a 100644 --- a/lib/feature/register/view/register_page.dart +++ b/lib/feature/register/view/register_page.dart @@ -11,6 +11,7 @@ class RegisterView extends GetView { @override Widget build(BuildContext context) { return Scaffold( + backgroundColor: const Color(0xFFFAFBFC), body: SafeArea( child: Padding( padding: const EdgeInsets.all(16.0), diff --git a/lib/feature/splash_screen/presentation/splash_screen_page.dart b/lib/feature/splash_screen/presentation/splash_screen_page.dart index c5c2dc5..4081eaa 100644 --- a/lib/feature/splash_screen/presentation/splash_screen_page.dart +++ b/lib/feature/splash_screen/presentation/splash_screen_page.dart @@ -29,6 +29,7 @@ class SplashScreenView extends StatelessWidget { }); return const Scaffold( + backgroundColor: Color(0xFFFAFBFC), body: Center( child: AppName(), ), From e2d801b8a515afabf1d4e62c230e5d45c6dd1043 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sat, 26 Apr 2025 15:54:17 +0700 Subject: [PATCH 011/104] feat: navigation --- lib/app/routes/app_pages.dart | 11 ++++ lib/app/routes/app_routes.dart | 2 + lib/feature/history/view/history_view.dart | 10 ++++ .../login/controllers/login_controller.dart | 2 +- .../bindings/navigation_binding.dart | 10 ++++ .../controllers/navigation_controller.dart | 9 +++ lib/feature/navigation/views/navbar_view.dart | 56 +++++++++++++++++++ lib/feature/profile/view/profile_view.dart | 10 ++++ lib/feature/search/view/search_view.dart | 10 ++++ .../presentation/splash_screen_page.dart | 2 +- 10 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 lib/feature/history/view/history_view.dart create mode 100644 lib/feature/navigation/bindings/navigation_binding.dart create mode 100644 lib/feature/navigation/controllers/navigation_controller.dart create mode 100644 lib/feature/navigation/views/navbar_view.dart create mode 100644 lib/feature/profile/view/profile_view.dart create mode 100644 lib/feature/search/view/search_view.dart diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index 7b56701..571f99b 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -4,6 +4,8 @@ import 'package:quiz_app/feature/home/binding/home_binding.dart'; import 'package:quiz_app/feature/home/view/home_page.dart'; import 'package:quiz_app/feature/login/bindings/login_binding.dart'; import 'package:quiz_app/feature/login/view/login_page.dart'; +import 'package:quiz_app/feature/navigation/bindings/navigation_binding.dart'; +import 'package:quiz_app/feature/navigation/views/navbar_view.dart'; import 'package:quiz_app/feature/register/binding/register_binding.dart'; import 'package:quiz_app/feature/register/view/register_page.dart'; import 'package:quiz_app/feature/splash_screen/presentation/splash_screen_page.dart'; @@ -32,5 +34,14 @@ class AppPages { binding: HomeBinding(), middlewares: [AuthMiddleware()], ), + GetPage( + name: AppRoutes.mainPage, + page: () => NavbarView(), + bindings: [ + NavbarBinding(), + HomeBinding(), + ], + middlewares: [AuthMiddleware()], + ) ]; } diff --git a/lib/app/routes/app_routes.dart b/lib/app/routes/app_routes.dart index bde6d89..00e7b0f 100644 --- a/lib/app/routes/app_routes.dart +++ b/lib/app/routes/app_routes.dart @@ -5,4 +5,6 @@ abstract class AppRoutes { static const loginPage = "/login"; static const registerPage = "/register"; static const homePage = '/home'; + + static const mainPage = '/main'; } diff --git a/lib/feature/history/view/history_view.dart b/lib/feature/history/view/history_view.dart new file mode 100644 index 0000000..e7ba104 --- /dev/null +++ b/lib/feature/history/view/history_view.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class HistoryView extends StatelessWidget { + const HistoryView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold(); + } +} diff --git a/lib/feature/login/controllers/login_controller.dart b/lib/feature/login/controllers/login_controller.dart index 8160f24..63eac8d 100644 --- a/lib/feature/login/controllers/login_controller.dart +++ b/lib/feature/login/controllers/login_controller.dart @@ -111,5 +111,5 @@ class LoginController extends GetxController { } } - void goToRegsPage() => Get.toNamed(AppRoutes.registerPage); + void goToRegsPage() => Get.toNamed(AppRoutes.mainPage); } diff --git a/lib/feature/navigation/bindings/navigation_binding.dart b/lib/feature/navigation/bindings/navigation_binding.dart new file mode 100644 index 0000000..a3d6e66 --- /dev/null +++ b/lib/feature/navigation/bindings/navigation_binding.dart @@ -0,0 +1,10 @@ +// feature/navbar/binding/navbar_binding.dart +import 'package:get/get.dart'; +import 'package:quiz_app/feature/navigation/controllers/navigation_controller.dart'; + +class NavbarBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => NavigationController()); + } +} diff --git a/lib/feature/navigation/controllers/navigation_controller.dart b/lib/feature/navigation/controllers/navigation_controller.dart new file mode 100644 index 0000000..96ade1c --- /dev/null +++ b/lib/feature/navigation/controllers/navigation_controller.dart @@ -0,0 +1,9 @@ +import 'package:get/get.dart'; + +class NavigationController extends GetxController { + RxInt selectedIndex = 0.obs; + + void changePage(int page) { + selectedIndex.value = page; + } +} diff --git a/lib/feature/navigation/views/navbar_view.dart b/lib/feature/navigation/views/navbar_view.dart new file mode 100644 index 0000000..b85ccc7 --- /dev/null +++ b/lib/feature/navigation/views/navbar_view.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/feature/history/view/history_view.dart'; +import 'package:quiz_app/feature/home/view/home_page.dart'; +import 'package:quiz_app/feature/navigation/controllers/navigation_controller.dart'; +import 'package:quiz_app/feature/profile/view/profile_view.dart'; +import 'package:quiz_app/feature/search/view/search_view.dart'; + +class NavbarView extends GetView { + const NavbarView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Obx(() { + switch (controller.selectedIndex.value) { + case 0: + return const HomeView(); + case 1: + return const SearchView(); + case 2: + return const HistoryView(); + case 3: + return const ProfileView(); + default: + return const HomeView(); + } + }), + bottomNavigationBar: Obx( + () => BottomNavigationBar( + type: BottomNavigationBarType.fixed, // <=== ini tambahan penting! + currentIndex: controller.selectedIndex.value, + onTap: controller.changePage, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.home), + label: 'Home', + ), + BottomNavigationBarItem( + icon: Icon(Icons.search), + label: 'Search', + ), + BottomNavigationBarItem( + icon: Icon(Icons.history), + label: 'History', + ), + BottomNavigationBarItem( + icon: Icon(Icons.person), + label: 'Profile', + ), + ], + ), + ), + ); + } +} diff --git a/lib/feature/profile/view/profile_view.dart b/lib/feature/profile/view/profile_view.dart new file mode 100644 index 0000000..bc41fd2 --- /dev/null +++ b/lib/feature/profile/view/profile_view.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class ProfileView extends StatelessWidget { + const ProfileView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold(); + } +} diff --git a/lib/feature/search/view/search_view.dart b/lib/feature/search/view/search_view.dart new file mode 100644 index 0000000..ea198aa --- /dev/null +++ b/lib/feature/search/view/search_view.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class SearchView extends StatelessWidget { + const SearchView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold(); + } +} diff --git a/lib/feature/splash_screen/presentation/splash_screen_page.dart b/lib/feature/splash_screen/presentation/splash_screen_page.dart index 4081eaa..eec56b5 100644 --- a/lib/feature/splash_screen/presentation/splash_screen_page.dart +++ b/lib/feature/splash_screen/presentation/splash_screen_page.dart @@ -15,7 +15,7 @@ class SplashScreenView extends StatelessWidget { await Future.delayed(const Duration(seconds: 2)); if (isLoggedIn) { - Get.offNamed(AppRoutes.homePage); + Get.offNamed(AppRoutes.mainPage); } else { Get.offNamed(AppRoutes.loginPage); } From baee8e35dbfdd7d603a0e96db579fd20894dc37c Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sat, 26 Apr 2025 16:06:03 +0700 Subject: [PATCH 012/104] feat: done slicing search --- lib/app/routes/app_pages.dart | 2 + .../search/binding/search_binding.dart | 9 ++ .../search/controller/search_controller.dart | 21 ++++ lib/feature/search/view/search_view.dart | 105 +++++++++++++++++- 4 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 lib/feature/search/binding/search_binding.dart create mode 100644 lib/feature/search/controller/search_controller.dart diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index 571f99b..ceac4d1 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -8,6 +8,7 @@ import 'package:quiz_app/feature/navigation/bindings/navigation_binding.dart'; import 'package:quiz_app/feature/navigation/views/navbar_view.dart'; import 'package:quiz_app/feature/register/binding/register_binding.dart'; import 'package:quiz_app/feature/register/view/register_page.dart'; +import 'package:quiz_app/feature/search/binding/search_binding.dart'; import 'package:quiz_app/feature/splash_screen/presentation/splash_screen_page.dart'; part 'app_routes.dart'; @@ -40,6 +41,7 @@ class AppPages { bindings: [ NavbarBinding(), HomeBinding(), + SearchBinding(), ], middlewares: [AuthMiddleware()], ) diff --git a/lib/feature/search/binding/search_binding.dart b/lib/feature/search/binding/search_binding.dart new file mode 100644 index 0000000..8ea4ccf --- /dev/null +++ b/lib/feature/search/binding/search_binding.dart @@ -0,0 +1,9 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/feature/search/controller/search_controller.dart'; + +class SearchBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => SearchQuizController()); + } +} diff --git a/lib/feature/search/controller/search_controller.dart b/lib/feature/search/controller/search_controller.dart new file mode 100644 index 0000000..f8c5808 --- /dev/null +++ b/lib/feature/search/controller/search_controller.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class SearchQuizController extends GetxController { + final searchController = TextEditingController(); + final searchText = ''.obs; + + @override + void onInit() { + super.onInit(); + searchController.addListener(() { + searchText.value = searchController.text; + }); + } + + @override + void onClose() { + searchController.dispose(); + super.onClose(); + } +} diff --git a/lib/feature/search/view/search_view.dart b/lib/feature/search/view/search_view.dart index ea198aa..b985385 100644 --- a/lib/feature/search/view/search_view.dart +++ b/lib/feature/search/view/search_view.dart @@ -1,10 +1,111 @@ import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/component/quiz_container_component.dart'; +import 'package:quiz_app/feature/search/controller/search_controller.dart'; -class SearchView extends StatelessWidget { +class SearchView extends GetView { const SearchView({super.key}); @override Widget build(BuildContext context) { - return Scaffold(); + return Scaffold( + backgroundColor: const Color(0xFFF8F9FB), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Obx(() { + final isSearching = controller.searchText.isNotEmpty; + + return ListView( + children: [ + _buildSearchBar(), + const SizedBox(height: 20), + if (isSearching) ...[ + _buildCategoryFilter(), + const SizedBox(height: 20), + const Text( + "Result", + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + const SizedBox(height: 10), + _buildQuizList(count: 5), + ] else ...[ + _buildSectionTitle("Rekomendasi Quiz"), + const SizedBox(height: 10), + _buildQuizList(), + const SizedBox(height: 30), + _buildSectionTitle("Quiz Populer"), + const SizedBox(height: 10), + _buildQuizList(), + ], + ], + ); + }), + ), + ), + ); + } + + Widget _buildSearchBar() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 6, + offset: Offset(0, 2), + ), + ], + ), + child: TextField( + controller: controller.searchController, + decoration: const InputDecoration( + hintText: 'Cari quiz...', + border: InputBorder.none, + icon: Icon(Icons.search), + ), + ), + ); + } + + Widget _buildCategoryFilter() { + final categories = ['Fisika', 'Matematika', 'Agama', 'English', 'Sejarah', 'Biologi']; + return Wrap( + spacing: 8, + runSpacing: 8, + children: categories.map((cat) { + return Chip( + label: Text(cat), + padding: const EdgeInsets.symmetric(horizontal: 12), + backgroundColor: Colors.white, + side: const BorderSide(color: Colors.black12), + ); + }).toList(), + ); + } + + Widget _buildSectionTitle(String title) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + Text( + "Lihat semua", + style: TextStyle(fontSize: 14, color: Colors.blue.shade700), + ), + ], + ); + } + + Widget _buildQuizList({int count = 3}) { + return Column( + children: List.generate(count, (_) => const QuizContainerComponent()), + ); } } From 26ed9797b8ac7fd6342308292abdfc6956d2458d Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sat, 26 Apr 2025 16:19:52 +0700 Subject: [PATCH 013/104] feat: slicing history page --- lib/app/routes/app_pages.dart | 2 + .../history/binding/history_binding.dart | 9 ++ .../controller/history_controller.dart | 46 +++++++ lib/feature/history/view/history_view.dart | 124 +++++++++++++++++- 4 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 lib/feature/history/binding/history_binding.dart create mode 100644 lib/feature/history/controller/history_controller.dart diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index ceac4d1..b989f22 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -1,5 +1,6 @@ import 'package:get/get_navigation/src/routes/get_route.dart'; import 'package:quiz_app/app/middleware/auth_middleware.dart'; +import 'package:quiz_app/feature/history/binding/history_binding.dart'; import 'package:quiz_app/feature/home/binding/home_binding.dart'; import 'package:quiz_app/feature/home/view/home_page.dart'; import 'package:quiz_app/feature/login/bindings/login_binding.dart'; @@ -42,6 +43,7 @@ class AppPages { NavbarBinding(), HomeBinding(), SearchBinding(), + HistoryBinding(), ], middlewares: [AuthMiddleware()], ) diff --git a/lib/feature/history/binding/history_binding.dart b/lib/feature/history/binding/history_binding.dart new file mode 100644 index 0000000..e9a4706 --- /dev/null +++ b/lib/feature/history/binding/history_binding.dart @@ -0,0 +1,9 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/feature/history/controller/history_controller.dart'; + +class HistoryBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => HistoryController()); + } +} diff --git a/lib/feature/history/controller/history_controller.dart b/lib/feature/history/controller/history_controller.dart new file mode 100644 index 0000000..8c79610 --- /dev/null +++ b/lib/feature/history/controller/history_controller.dart @@ -0,0 +1,46 @@ +import 'package:get/get.dart'; + +class HistoryItem { + final String title; + final String date; + final int score; + final int totalQuestions; + final String duration; + + HistoryItem({ + required this.title, + required this.date, + required this.score, + required this.totalQuestions, + required this.duration, + }); +} + +class HistoryController extends GetxController { + final historyList = [].obs; + + @override + void onInit() { + super.onInit(); + loadDummyHistory(); + } + + void loadDummyHistory() { + historyList.value = [ + HistoryItem( + title: "Fisika Dasar", + date: "24 April 2025", + score: 8, + totalQuestions: 10, + duration: "5m 21s", + ), + HistoryItem( + title: "Sejarah Indonesia", + date: "22 April 2025", + score: 7, + totalQuestions: 10, + duration: "4m 35s", + ), + ]; + } +} diff --git a/lib/feature/history/view/history_view.dart b/lib/feature/history/view/history_view.dart index e7ba104..d475e22 100644 --- a/lib/feature/history/view/history_view.dart +++ b/lib/feature/history/view/history_view.dart @@ -1,10 +1,128 @@ import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/feature/history/controller/history_controller.dart'; -class HistoryView extends StatelessWidget { +class HistoryView extends GetView { const HistoryView({super.key}); - @override Widget build(BuildContext context) { - return Scaffold(); + return Scaffold( + backgroundColor: const Color(0xFFF8F9FB), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Obx(() { + final historyList = controller.historyList; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Riwayat Kuis", + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + const Text( + "Lihat kembali hasil kuis yang telah kamu kerjakan", + style: TextStyle( + fontSize: 14, + color: Colors.grey, + ), + ), + const SizedBox(height: 20), + if (historyList.isEmpty) + const Expanded( + child: Center( + child: Text( + "Belum ada kuis yang dikerjakan.", + style: TextStyle(fontSize: 16, color: Colors.grey), + ), + ), + ) + else + Expanded( + child: ListView.builder( + itemCount: historyList.length, + itemBuilder: (context, index) { + final item = historyList[index]; + return _buildHistoryCard(item); + }, + ), + ) + ], + ); + }), + ), + ), + ); + } + + Widget _buildHistoryCard(HistoryItem item) { + return Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 4, + offset: Offset(0, 2), + ) + ], + ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: Colors.blue.shade100, + shape: BoxShape.circle, + ), + child: const Icon(Icons.history, color: Colors.blue), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 4), + Text( + item.date, + style: const TextStyle(fontSize: 12, color: Colors.grey), + ), + const SizedBox(height: 8), + Row( + children: [ + const Icon(Icons.check_circle, size: 14, color: Colors.green), + const SizedBox(width: 4), + Text( + "Skor: ${item.score}/${item.totalQuestions}", + style: const TextStyle(fontSize: 12), + ), + const SizedBox(width: 16), + const Icon(Icons.timer, size: 14, color: Colors.grey), + const SizedBox(width: 4), + Text( + item.duration, + style: const TextStyle(fontSize: 12), + ), + ], + ), + ], + ), + ) + ], + ), + ); } } From dbb3bd8c9019e9e14a2ce3cad12d70bbfcbce5d0 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sat, 26 Apr 2025 16:25:26 +0700 Subject: [PATCH 014/104] feat: slicing profile page --- lib/app/routes/app_pages.dart | 2 + .../profile/binding/profile_binding.dart | 9 ++ .../controller/profile_controller.dart | 17 +++ lib/feature/profile/view/profile_view.dart | 100 +++++++++++++++++- 4 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 lib/feature/profile/binding/profile_binding.dart create mode 100644 lib/feature/profile/controller/profile_controller.dart diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index b989f22..522118a 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -7,6 +7,7 @@ import 'package:quiz_app/feature/login/bindings/login_binding.dart'; import 'package:quiz_app/feature/login/view/login_page.dart'; import 'package:quiz_app/feature/navigation/bindings/navigation_binding.dart'; import 'package:quiz_app/feature/navigation/views/navbar_view.dart'; +import 'package:quiz_app/feature/profile/binding/profile_binding.dart'; import 'package:quiz_app/feature/register/binding/register_binding.dart'; import 'package:quiz_app/feature/register/view/register_page.dart'; import 'package:quiz_app/feature/search/binding/search_binding.dart'; @@ -44,6 +45,7 @@ class AppPages { HomeBinding(), SearchBinding(), HistoryBinding(), + ProfileBinding(), ], middlewares: [AuthMiddleware()], ) diff --git a/lib/feature/profile/binding/profile_binding.dart b/lib/feature/profile/binding/profile_binding.dart new file mode 100644 index 0000000..e8d0a25 --- /dev/null +++ b/lib/feature/profile/binding/profile_binding.dart @@ -0,0 +1,9 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/feature/profile/controller/profile_controller.dart'; + +class ProfileBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => ProfileController()); + } +} diff --git a/lib/feature/profile/controller/profile_controller.dart b/lib/feature/profile/controller/profile_controller.dart new file mode 100644 index 0000000..0e0df07 --- /dev/null +++ b/lib/feature/profile/controller/profile_controller.dart @@ -0,0 +1,17 @@ +import 'package:get/get.dart'; + +class ProfileController extends GetxController { + final userName = 'Alhidan Robbani'.obs; + final email = 'alhidan@example.com'.obs; + + final totalQuizzes = 12.obs; + final avgScore = 85.obs; + + void logout() { + print("Logout pressed"); + } + + void editProfile() { + print("Edit profile pressed"); + } +} diff --git a/lib/feature/profile/view/profile_view.dart b/lib/feature/profile/view/profile_view.dart index bc41fd2..bf8c68a 100644 --- a/lib/feature/profile/view/profile_view.dart +++ b/lib/feature/profile/view/profile_view.dart @@ -1,10 +1,106 @@ import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/feature/profile/controller/profile_controller.dart'; -class ProfileView extends StatelessWidget { +class ProfileView extends GetView { const ProfileView({super.key}); @override Widget build(BuildContext context) { - return Scaffold(); + return Scaffold( + backgroundColor: const Color(0xFFF8F9FB), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(20), + child: Obx(() { + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 20), + _buildAvatar(), + const SizedBox(height: 12), + Text( + controller.userName.value, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Text( + controller.email.value, + style: const TextStyle(fontSize: 14, color: Colors.grey), + ), + const SizedBox(height: 24), + _buildStats(), + const SizedBox(height: 32), + _buildActionButton("Edit Profil", Icons.edit, controller.editProfile), + const SizedBox(height: 12), + _buildActionButton("Logout", Icons.logout, controller.logout, isDestructive: true), + ], + ); + }), + ), + ), + ); + } + + Widget _buildAvatar() { + return const CircleAvatar( + radius: 45, + backgroundColor: Colors.blueAccent, + child: Icon(Icons.person, size: 50, color: Colors.white), + ); + } + + Widget _buildStats() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 6, + offset: Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildStatItem("Total Quiz", controller.totalQuizzes.value.toString()), + const SizedBox(width: 16), + _buildStatItem("Skor Rata-rata", "${controller.avgScore.value}%"), + ], + ), + ); + } + + Widget _buildStatItem(String label, String value) { + return Column( + children: [ + Text(value, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + Text(label, style: const TextStyle(fontSize: 13, color: Colors.grey)), + ], + ); + } + + Widget _buildActionButton(String title, IconData icon, VoidCallback onPressed, {bool isDestructive = false}) { + return SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + icon: Icon(icon, color: isDestructive ? Colors.red : Colors.white), + label: Text( + title, + style: TextStyle(color: isDestructive ? Colors.red : Colors.white), + ), + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: isDestructive ? Colors.red.shade50 : Colors.blueAccent, + padding: const EdgeInsets.symmetric(vertical: 14), + side: isDestructive ? const BorderSide(color: Colors.red) : BorderSide.none, + ), + ), + ); } } From 39ab35b2a8a9557a598d789ae1cb7a8e1c295a90 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sat, 26 Apr 2025 20:58:16 +0700 Subject: [PATCH 015/104] feat: add quiz controller --- lib/app/bindings/initial_bindings.dart | 2 ++ lib/data/controllers/user_controller.dart | 28 +++++++++++++++++++ lib/feature/home/binding/home_binding.dart | 2 +- .../home/controller/home_controller.dart | 25 +++-------------- lib/feature/home/view/home_page.dart | 2 +- .../controller/profile_controller.dart | 8 ++++-- lib/feature/profile/view/profile_view.dart | 18 ++++++++---- 7 files changed, 55 insertions(+), 30 deletions(-) create mode 100644 lib/data/controllers/user_controller.dart diff --git a/lib/app/bindings/initial_bindings.dart b/lib/app/bindings/initial_bindings.dart index d9631c9..189bb2e 100644 --- a/lib/app/bindings/initial_bindings.dart +++ b/lib/app/bindings/initial_bindings.dart @@ -1,4 +1,5 @@ import 'package:get/get.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/providers/dio_client.dart'; import 'package:quiz_app/data/services/user_storage_service.dart'; @@ -7,5 +8,6 @@ class InitialBindings extends Bindings { void dependencies() { Get.put(UserStorageService()); Get.putAsync(() => ApiClient().init()); + Get.put(UserController(Get.find())); } } diff --git a/lib/data/controllers/user_controller.dart b/lib/data/controllers/user_controller.dart new file mode 100644 index 0000000..28a5ef4 --- /dev/null +++ b/lib/data/controllers/user_controller.dart @@ -0,0 +1,28 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/data/services/user_storage_service.dart'; + +class UserController extends GetxController { + final UserStorageService _userStorageService; + + UserController(this._userStorageService); + + Rx userName = "".obs; + Rx userImage = Rx(null); + Rx email = "".obs; + + @override + void onInit() { + loadUser(); + super.onInit(); + } + + Future loadUser() async { + final data = await _userStorageService.loadUser(); + if (data != null) { + userName.value = data.name; + userImage.value = data.picUrl; + email.value = data.email; + print("Loaded user: ${data.toJson()}"); + } + } +} diff --git a/lib/feature/home/binding/home_binding.dart b/lib/feature/home/binding/home_binding.dart index d2028f7..78eabad 100644 --- a/lib/feature/home/binding/home_binding.dart +++ b/lib/feature/home/binding/home_binding.dart @@ -5,6 +5,6 @@ import 'package:quiz_app/feature/home/controller/home_controller.dart'; class HomeBinding extends Bindings { @override void dependencies() { - Get.lazyPut(() => HomeController(Get.find())); + Get.lazyPut(() => HomeController()); } } diff --git a/lib/feature/home/controller/home_controller.dart b/lib/feature/home/controller/home_controller.dart index aef1787..a388524 100644 --- a/lib/feature/home/controller/home_controller.dart +++ b/lib/feature/home/controller/home_controller.dart @@ -1,26 +1,9 @@ import 'package:get/get.dart'; -import 'package:quiz_app/data/models/login/login_response_model.dart'; -import 'package:quiz_app/data/services/user_storage_service.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; class HomeController extends GetxController { - final UserStorageService _userStorageService; + final UserController _userController = Get.find(); - HomeController(this._userStorageService); - - Rx userName = "Dani".obs; - String? userImage; - - @override - void onInit() { - getUserData(); - super.onInit(); - } - - Future getUserData() async { - LoginResponseModel? data = await _userStorageService.loadUser(); - if (data == null) return; - print("User data: ${data.toJson()}"); - userName.value = data.name; - userImage = data.picUrl; - } + Rx get userName => _userController.userName; + Rx get userImage => _userController.userImage; } diff --git a/lib/feature/home/view/home_page.dart b/lib/feature/home/view/home_page.dart index 5a8fd41..5ac1288 100644 --- a/lib/feature/home/view/home_page.dart +++ b/lib/feature/home/view/home_page.dart @@ -23,7 +23,7 @@ class HomeView extends GetView { Obx( () => UserGretingsComponent( userName: controller.userName.value, - userImage: controller.userImage, + userImage: controller.userImage.value, ), ), const SizedBox(height: 20), diff --git a/lib/feature/profile/controller/profile_controller.dart b/lib/feature/profile/controller/profile_controller.dart index 0e0df07..a52dffe 100644 --- a/lib/feature/profile/controller/profile_controller.dart +++ b/lib/feature/profile/controller/profile_controller.dart @@ -1,8 +1,12 @@ import 'package:get/get.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; class ProfileController extends GetxController { - final userName = 'Alhidan Robbani'.obs; - final email = 'alhidan@example.com'.obs; + final UserController _userController = Get.find(); + + Rx get userName => _userController.userName; + Rx get email => _userController.email; + Rx get userImage => _userController.userImage; final totalQuizzes = 12.obs; final avgScore = 85.obs; diff --git a/lib/feature/profile/view/profile_view.dart b/lib/feature/profile/view/profile_view.dart index bf8c68a..cfc0cd0 100644 --- a/lib/feature/profile/view/profile_view.dart +++ b/lib/feature/profile/view/profile_view.dart @@ -43,11 +43,19 @@ class ProfileView extends GetView { } Widget _buildAvatar() { - return const CircleAvatar( - radius: 45, - backgroundColor: Colors.blueAccent, - child: Icon(Icons.person, size: 50, color: Colors.white), - ); + if (controller.userImage.value != null) { + return CircleAvatar( + radius: 45, + backgroundColor: Colors.blueAccent, + backgroundImage: NetworkImage(controller.userImage.value!), + ); + } else { + return const CircleAvatar( + radius: 45, + backgroundColor: Colors.blueAccent, + child: Icon(Icons.person, size: 50, color: Colors.white), + ); + } } Widget _buildStats() { From 05a22f33602407b7a4040df6c19922c6f5b5f044 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 27 Apr 2025 00:30:25 +0700 Subject: [PATCH 016/104] feat: interface on the quiz creation --- lib/app/const/colors/app_colors.dart | 14 ++ lib/app/routes/app_pages.dart | 9 +- lib/app/routes/app_routes.dart | 2 + lib/component/global_text_field.dart | 31 +++- lib/component/quiz_container_component.dart | 3 +- lib/feature/home/binding/home_binding.dart | 1 - .../home/controller/home_controller.dart | 3 + .../home/view/component/search_component.dart | 3 +- lib/feature/home/view/home_page.dart | 5 +- .../login/controllers/login_controller.dart | 4 +- lib/feature/login/view/login_page.dart | 3 +- .../binding/quiz_creation_binding.dart | 9 + .../controller/quiz_creation_controller.dart | 17 ++ .../component/custom_question_component.dart | 156 ++++++++++++++++++ .../component/fill_the_blank_component.dart | 31 ++++ .../view/component/generate_component.dart | 60 +++++++ .../component/option_question_component.dart | 84 ++++++++++ .../component/true_or_false_component.dart | 70 ++++++++ .../view/quiz_creation_view.dart | 99 +++++++++++ lib/feature/register/view/register_page.dart | 3 +- .../presentation/splash_screen_page.dart | 3 +- 21 files changed, 591 insertions(+), 19 deletions(-) create mode 100644 lib/app/const/colors/app_colors.dart create mode 100644 lib/feature/quiz_creation/binding/quiz_creation_binding.dart create mode 100644 lib/feature/quiz_creation/controller/quiz_creation_controller.dart create mode 100644 lib/feature/quiz_creation/view/component/custom_question_component.dart create mode 100644 lib/feature/quiz_creation/view/component/fill_the_blank_component.dart create mode 100644 lib/feature/quiz_creation/view/component/generate_component.dart create mode 100644 lib/feature/quiz_creation/view/component/option_question_component.dart create mode 100644 lib/feature/quiz_creation/view/component/true_or_false_component.dart create mode 100644 lib/feature/quiz_creation/view/quiz_creation_view.dart diff --git a/lib/app/const/colors/app_colors.dart b/lib/app/const/colors/app_colors.dart new file mode 100644 index 0000000..2e8cf87 --- /dev/null +++ b/lib/app/const/colors/app_colors.dart @@ -0,0 +1,14 @@ +import 'dart:ui'; + +class AppColors { + static const Color primaryBlue = Color(0xFF0052CC); + static const Color darkText = Color(0xFF172B4D); + static const Color softGrayText = Color(0xFF6B778C); + static const Color background = Color(0xFFFAFBFC); + + static const Color borderLight = Color(0xFFE1E4E8); + static const Color accentBlue = Color(0xFFD6E4FF); + static const Color shadowPrimary = Color(0x330052CC); + static const Color disabledBackground = Color(0xFFE0E0E0); + static const Color disabledText = Color(0xFF9E9E9E); +} diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index 522118a..924c8ab 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -8,6 +8,8 @@ import 'package:quiz_app/feature/login/view/login_page.dart'; import 'package:quiz_app/feature/navigation/bindings/navigation_binding.dart'; import 'package:quiz_app/feature/navigation/views/navbar_view.dart'; import 'package:quiz_app/feature/profile/binding/profile_binding.dart'; +import 'package:quiz_app/feature/quiz_creation/binding/quiz_creation_binding.dart'; +import 'package:quiz_app/feature/quiz_creation/view/quiz_creation_view.dart'; import 'package:quiz_app/feature/register/binding/register_binding.dart'; import 'package:quiz_app/feature/register/view/register_page.dart'; import 'package:quiz_app/feature/search/binding/search_binding.dart'; @@ -48,6 +50,11 @@ class AppPages { ProfileBinding(), ], middlewares: [AuthMiddleware()], - ) + ), + GetPage( + name: AppRoutes.quizCreatePage, + page: () => QuizCreationView(), + binding: QuizCreationBinding(), + ), ]; } diff --git a/lib/app/routes/app_routes.dart b/lib/app/routes/app_routes.dart index 00e7b0f..52906e7 100644 --- a/lib/app/routes/app_routes.dart +++ b/lib/app/routes/app_routes.dart @@ -7,4 +7,6 @@ abstract class AppRoutes { static const homePage = '/home'; static const mainPage = '/main'; + + static const quizCreatePage = "/quiz/creation"; } diff --git a/lib/component/global_text_field.dart b/lib/component/global_text_field.dart index f855ad4..ca8369d 100644 --- a/lib/component/global_text_field.dart +++ b/lib/component/global_text_field.dart @@ -4,6 +4,7 @@ class GlobalTextField extends StatelessWidget { final TextEditingController controller; final String? hintText; final String? labelText; + final int limitTextLine; final bool isPassword; final bool obscureText; final VoidCallback? onToggleVisibility; @@ -13,6 +14,7 @@ class GlobalTextField extends StatelessWidget { required this.controller, this.hintText, this.labelText, + this.limitTextLine = 1, this.isPassword = false, this.obscureText = false, this.onToggleVisibility, @@ -23,31 +25,44 @@ class GlobalTextField extends StatelessWidget { return TextField( controller: controller, obscureText: isPassword ? obscureText : false, + maxLines: limitTextLine, // <-- ini tambahan dari limitTextLine decoration: InputDecoration( labelText: labelText, - labelStyle: const TextStyle(color: Color(0xFF6B778C), fontSize: 14), + labelStyle: const TextStyle( + color: Color(0xFF6B778C), + fontSize: 14, + ), hintText: hintText, - hintStyle: const TextStyle(color: Color(0xFF6B778C), fontSize: 14), + hintStyle: const TextStyle( + color: Color(0xFF6B778C), + fontSize: 14, + ), filled: true, - fillColor: const Color.fromARGB(255, 234, 234, 235), // Background soft white - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + fillColor: Color.fromARGB(255, 234, 234, 235), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 16, + ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: BorderSide(color: Colors.transparent), + borderSide: BorderSide.none, ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: BorderSide(color: Colors.transparent), + borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: Color(0xFF0052CC), width: 2), + borderSide: const BorderSide( + color: Color(0xFF0052CC), + width: 2, + ), ), suffixIcon: isPassword ? IconButton( icon: Icon( obscureText ? Icons.visibility_off : Icons.visibility, - color: const Color(0xFF6B778C), + color: Color(0xFF6B778C), ), onPressed: onToggleVisibility, ) diff --git a/lib/component/quiz_container_component.dart b/lib/component/quiz_container_component.dart index 0a1d448..b70cb57 100644 --- a/lib/component/quiz_container_component.dart +++ b/lib/component/quiz_container_component.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; class QuizContainerComponent extends StatelessWidget { const QuizContainerComponent({super.key}); @@ -8,7 +9,7 @@ class QuizContainerComponent extends StatelessWidget { return Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( - color: Color(0xFFFAFBFC), + color: AppColors.background, borderRadius: BorderRadius.circular(12), border: Border.all( color: Color(0xFFE1E4E8), diff --git a/lib/feature/home/binding/home_binding.dart b/lib/feature/home/binding/home_binding.dart index 78eabad..9adecc5 100644 --- a/lib/feature/home/binding/home_binding.dart +++ b/lib/feature/home/binding/home_binding.dart @@ -1,5 +1,4 @@ import 'package:get/get.dart'; -import 'package:quiz_app/data/services/user_storage_service.dart'; import 'package:quiz_app/feature/home/controller/home_controller.dart'; class HomeBinding extends Bindings { diff --git a/lib/feature/home/controller/home_controller.dart b/lib/feature/home/controller/home_controller.dart index a388524..862f335 100644 --- a/lib/feature/home/controller/home_controller.dart +++ b/lib/feature/home/controller/home_controller.dart @@ -1,4 +1,5 @@ import 'package:get/get.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; class HomeController extends GetxController { @@ -6,4 +7,6 @@ class HomeController extends GetxController { Rx get userName => _userController.userName; Rx get userImage => _userController.userImage; + + void goToQuizCreation() => Get.toNamed(AppRoutes.quizCreatePage); } diff --git a/lib/feature/home/view/component/search_component.dart b/lib/feature/home/view/component/search_component.dart index eef2c2f..7409fa2 100644 --- a/lib/feature/home/view/component/search_component.dart +++ b/lib/feature/home/view/component/search_component.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; class SearchComponent extends StatelessWidget { const SearchComponent({super.key}); @@ -9,7 +10,7 @@ class SearchComponent extends StatelessWidget { margin: const EdgeInsets.symmetric(vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), decoration: BoxDecoration( - color: const Color(0xFFFAFBFC), // Soft background + color: AppColors.background, // Soft background borderRadius: BorderRadius.circular(10), border: Border.all(color: Color(0xFFE1E4E8)), // Light border ), diff --git a/lib/feature/home/view/home_page.dart b/lib/feature/home/view/home_page.dart index 5ac1288..a696a49 100644 --- a/lib/feature/home/view/home_page.dart +++ b/lib/feature/home/view/home_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; import 'package:quiz_app/feature/home/controller/home_controller.dart'; import 'package:quiz_app/feature/home/view/component/button_option.dart'; import 'package:quiz_app/feature/home/view/component/recomendation_component.dart'; @@ -12,7 +13,7 @@ class HomeView extends GetView { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: const Color(0xFFFAFBFC), + backgroundColor: AppColors.background, body: SafeArea( child: ListView( children: [ @@ -32,7 +33,7 @@ class HomeView extends GetView { ), // ButtonOption di luar Padding ButtonOption( - onCreate: () {}, + onCreate: controller.goToQuizCreation, onCreateRoom: () {}, onJoinRoom: () {}, ), diff --git a/lib/feature/login/controllers/login_controller.dart b/lib/feature/login/controllers/login_controller.dart index 63eac8d..c052869 100644 --- a/lib/feature/login/controllers/login_controller.dart +++ b/lib/feature/login/controllers/login_controller.dart @@ -73,7 +73,7 @@ class LoginController extends GetxController { _userStorageService.isLogged = true; - Get.toNamed(AppRoutes.homePage); + Get.toNamed(AppRoutes.mainPage); } catch (e, stackTrace) { logC.e(e, stackTrace: stackTrace); Get.snackbar("Error", "Failed to connect to server"); @@ -104,7 +104,7 @@ class LoginController extends GetxController { _userStorageService.isLogged = true; - Get.toNamed(AppRoutes.homePage); + Get.toNamed(AppRoutes.mainPage); } catch (e, stackTrace) { logC.e("Google Sign-In Error: $e", stackTrace: stackTrace); Get.snackbar("Error", "Google sign-in error"); diff --git a/lib/feature/login/view/login_page.dart b/lib/feature/login/view/login_page.dart index 9840f42..85d7258 100644 --- a/lib/feature/login/view/login_page.dart +++ b/lib/feature/login/view/login_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; import 'package:quiz_app/component/app_name.dart'; import 'package:quiz_app/component/global_button.dart'; import 'package:quiz_app/component/global_text_field.dart'; @@ -14,7 +15,7 @@ class LoginView extends GetView { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: const Color(0xFFFAFBFC), // background soft clean + backgroundColor: AppColors.background, // background soft clean body: SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), diff --git a/lib/feature/quiz_creation/binding/quiz_creation_binding.dart b/lib/feature/quiz_creation/binding/quiz_creation_binding.dart new file mode 100644 index 0000000..4c3154a --- /dev/null +++ b/lib/feature/quiz_creation/binding/quiz_creation_binding.dart @@ -0,0 +1,9 @@ +import "package:get/get.dart"; +import "package:quiz_app/feature/quiz_creation/controller/quiz_creation_controller.dart"; + +class QuizCreationBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => QuizCreationController()); + } +} diff --git a/lib/feature/quiz_creation/controller/quiz_creation_controller.dart b/lib/feature/quiz_creation/controller/quiz_creation_controller.dart new file mode 100644 index 0000000..04623e6 --- /dev/null +++ b/lib/feature/quiz_creation/controller/quiz_creation_controller.dart @@ -0,0 +1,17 @@ +import 'package:flutter/widgets.dart'; +import 'package:get/get.dart'; + +class QuizCreationController extends GetxController { + TextEditingController questionTC = TextEditingController(); + TextEditingController answerTC = TextEditingController(); + + RxBool isGenerate = true.obs; + + Rx currentQuestionType = QuestionType.fillTheBlank.obs; + + onCreationTypeChange(bool value) => isGenerate.value = value; + + onQuestionTypeChange(QuestionType type) => currentQuestionType.value = type; +} + +enum QuestionType { fillTheBlank, option, trueOrFalse } diff --git a/lib/feature/quiz_creation/view/component/custom_question_component.dart b/lib/feature/quiz_creation/view/component/custom_question_component.dart new file mode 100644 index 0000000..0c02854 --- /dev/null +++ b/lib/feature/quiz_creation/view/component/custom_question_component.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/feature/quiz_creation/controller/quiz_creation_controller.dart'; +import 'package:quiz_app/feature/quiz_creation/view/component/fill_the_blank_component.dart'; +import 'package:quiz_app/feature/quiz_creation/view/component/option_question_component.dart'; +import 'package:quiz_app/feature/quiz_creation/view/component/true_or_false_component.dart'; + +class CustomQuestionComponent extends GetView { + const CustomQuestionComponent({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + _buildNumberPicker(), + const SizedBox(height: 20), + _buildQuizTypeSelector(), + const SizedBox(height: 20), + _questionTypeValue(), + const SizedBox(height: 20), + _buildDurationDropdown(), + ], + ); + } + + Widget _questionTypeValue() { + return Obx(() { + switch (controller.currentQuestionType.value) { + case QuestionType.fillTheBlank: + return FillTheBlankComponent( + questionTC: controller.questionTC, + answerTC: controller.answerTC, + ); + case QuestionType.option: + return OptionQuestionComponent( + questionTC: TextEditingController(), + optionTCList: List.generate(4, (index) => TextEditingController()), + ); + case QuestionType.trueOrFalse: + return TrueFalseQuestionComponent(questionTC: controller.questionTC); + } + }); + } + + Widget _buildNumberPicker() { + return Wrap( + spacing: 8, + runSpacing: 8, + children: List.generate(14, (index) { + return Container( + width: 42, + height: 42, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.borderLight), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(2, 2), + ), + ], + ), + child: Text( + '${index + 1}', + style: const TextStyle( + fontWeight: FontWeight.w600, + color: AppColors.darkText, + ), + ), + ); + }), + ); + } + + Widget _buildQuizTypeSelector() { + return Container( + decoration: BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.borderLight), + ), + child: Row( + children: [ + _buildQuizTypeButton( + 'Fill the Blanks', + type: QuestionType.fillTheBlank, + ), + _buildQuizTypeButton( + 'Option', + type: QuestionType.option, + ), + _buildQuizTypeButton( + 'True / False', + type: QuestionType.trueOrFalse, + ), + ], + ), + ); + } + + Widget _buildQuizTypeButton(String label, {required QuestionType type}) { + return Expanded( + child: Obx(() { + final bool isSelected = controller.currentQuestionType.value == type; + return GestureDetector( + onTap: () => controller.onQuestionTypeChange(type), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: isSelected ? AppColors.primaryBlue : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + alignment: Alignment.center, + child: Text( + label, + style: TextStyle( + color: isSelected ? Colors.white : AppColors.softGrayText, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + ), + ); + }), + ); + } + + Widget _buildDurationDropdown() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.borderLight), + ), + child: DropdownButtonFormField( + value: '1 minute', + decoration: const InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric(vertical: 14), + ), + style: const TextStyle(color: AppColors.darkText, fontWeight: FontWeight.w500), + items: const [ + DropdownMenuItem(value: '30 seconds', child: Text('30 seconds')), + DropdownMenuItem(value: '1 minute', child: Text('1 minute')), + DropdownMenuItem(value: '2 minutes', child: Text('2 minutes')), + ], + onChanged: (value) {}, + ), + ); + } +} diff --git a/lib/feature/quiz_creation/view/component/fill_the_blank_component.dart b/lib/feature/quiz_creation/view/component/fill_the_blank_component.dart new file mode 100644 index 0000000..ab411ea --- /dev/null +++ b/lib/feature/quiz_creation/view/component/fill_the_blank_component.dart @@ -0,0 +1,31 @@ +import 'package:flutter/widgets.dart'; +import 'package:quiz_app/component/global_text_field.dart'; +import 'package:quiz_app/component/label_text_field.dart'; + +class FillTheBlankComponent extends StatelessWidget { + final TextEditingController questionTC; + final TextEditingController answerTC; + + const FillTheBlankComponent({super.key, required this.questionTC, required this.answerTC}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + LabelTextField(label: "Pertanyaan"), + GlobalTextField( + controller: questionTC, + limitTextLine: 3, + hintText: "Tulis Pertanyaan", + ), + const SizedBox(height: 15), + LabelTextField(label: "Jawaban"), + GlobalTextField( + controller: answerTC, + hintText: "Tulis Jawaban", + ), + const SizedBox(height: 20), + ], + ); + } +} diff --git a/lib/feature/quiz_creation/view/component/generate_component.dart b/lib/feature/quiz_creation/view/component/generate_component.dart new file mode 100644 index 0000000..70ef06a --- /dev/null +++ b/lib/feature/quiz_creation/view/component/generate_component.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/feature/quiz_creation/controller/quiz_creation_controller.dart'; + +class GenerateComponent extends GetView { + const GenerateComponent({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Unggah file materi kamu (PDF atau Word) untuk membuat soal otomatis.", + style: TextStyle( + fontSize: 14, + color: Color(0xFF6B778C), + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 16), + GestureDetector( + onTap: () {}, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 30), + decoration: BoxDecoration( + color: const Color(0xFFF0F2F5), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.shade300), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Icon(Icons.insert_drive_file, size: 50, color: Color(0xFF6B778C)), + SizedBox(height: 10), + Text( + "Upload PDF atau Word", + style: TextStyle( + fontSize: 16, + color: Color(0xFF6B778C), + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 8), + Text( + "Max 10 MB", + style: TextStyle( + fontSize: 12, + color: Color(0xFF9FA8B2), + ), + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/feature/quiz_creation/view/component/option_question_component.dart b/lib/feature/quiz_creation/view/component/option_question_component.dart new file mode 100644 index 0000000..1e46db0 --- /dev/null +++ b/lib/feature/quiz_creation/view/component/option_question_component.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:quiz_app/component/global_text_field.dart'; +import 'package:quiz_app/component/label_text_field.dart'; + +class OptionQuestionComponent extends StatefulWidget { + final TextEditingController questionTC; + final List optionTCList; + + const OptionQuestionComponent({ + super.key, + required this.questionTC, + required this.optionTCList, + }); + + @override + State createState() => _OptionQuestionComponentState(); +} + +class _OptionQuestionComponentState extends State { + String? selectedCorrectAnswer; // A, B, C, D + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // Pertanyaan + LabelTextField(label: "Pertanyaan"), + GlobalTextField( + controller: widget.questionTC, + limitTextLine: 3, + hintText: "Tulis Pertanyaan", + ), + const SizedBox(height: 15), + + // Pilihan A, B, C, D + ...List.generate(widget.optionTCList.length, (index) { + return Column( + children: [ + LabelTextField(label: "Pilihan ${String.fromCharCode(65 + index)}"), + GlobalTextField( + controller: widget.optionTCList[index], + hintText: "Tulis Pilihan ${String.fromCharCode(65 + index)}", + ), + const SizedBox(height: 10), + ], + ); + }), + + // Jawaban Benar Dropdown + const SizedBox(height: 10), + LabelTextField(label: "Jawaban Benar"), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: selectedCorrectAnswer, + hint: const Text('Pilih Jawaban Benar'), + isExpanded: true, + items: List.generate(widget.optionTCList.length, (index) { + final optionLabel = String.fromCharCode(65 + index); // 'A', 'B', 'C', etc. + return DropdownMenuItem( + value: optionLabel, + child: Text(optionLabel), + ); + }), + onChanged: (value) { + setState(() { + selectedCorrectAnswer = value; + }); + }, + ), + ), + ), + + const SizedBox(height: 20), + ], + ); + } +} diff --git a/lib/feature/quiz_creation/view/component/true_or_false_component.dart b/lib/feature/quiz_creation/view/component/true_or_false_component.dart new file mode 100644 index 0000000..7523842 --- /dev/null +++ b/lib/feature/quiz_creation/view/component/true_or_false_component.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:quiz_app/component/global_text_field.dart'; +import 'package:quiz_app/component/label_text_field.dart'; + +class TrueFalseQuestionComponent extends StatefulWidget { + final TextEditingController questionTC; + + const TrueFalseQuestionComponent({ + super.key, + required this.questionTC, + }); + + @override + State createState() => _TrueFalseQuestionComponentState(); +} + +class _TrueFalseQuestionComponentState extends State { + bool? selectedAnswer; // true or false + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // Pertanyaan + LabelTextField(label: "Pertanyaan"), + GlobalTextField( + controller: widget.questionTC, + limitTextLine: 3, + hintText: "Tulis Pertanyaan", + ), + const SizedBox(height: 15), + + // Jawaban Dropdown + LabelTextField(label: "Jawaban Benar"), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: selectedAnswer, + hint: const Text('Pilih Jawaban Benar'), + isExpanded: true, + items: const [ + DropdownMenuItem( + value: true, + child: Text('True'), + ), + DropdownMenuItem( + value: false, + child: Text('False'), + ), + ], + onChanged: (value) { + setState(() { + selectedAnswer = value; + }); + }, + ), + ), + ), + + const SizedBox(height: 20), + ], + ); + } +} diff --git a/lib/feature/quiz_creation/view/quiz_creation_view.dart b/lib/feature/quiz_creation/view/quiz_creation_view.dart new file mode 100644 index 0000000..eede0b6 --- /dev/null +++ b/lib/feature/quiz_creation/view/quiz_creation_view.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/feature/quiz_creation/controller/quiz_creation_controller.dart'; +import 'package:quiz_app/feature/quiz_creation/view/component/custom_question_component.dart'; +import 'package:quiz_app/feature/quiz_creation/view/component/generate_component.dart'; + +class QuizCreationView extends GetView { + const QuizCreationView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.background, + elevation: 0, + title: const Text( + 'Create Quiz', + style: TextStyle( + fontWeight: FontWeight.bold, + color: AppColors.darkText, + ), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new_rounded, color: AppColors.darkText), + onPressed: () => Navigator.pop(context), + ), + centerTitle: true, + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildModeSelector(), + const SizedBox(height: 20), + Obx( + () => controller.isGenerate.value ? GenerateComponent() : CustomQuestionComponent(), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildModeSelector() { + return Container( + decoration: BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.borderLight), + ), + child: Row( + children: [ + _buildModeButton('Generate', controller.isGenerate, true), + _buildModeButton('Manual', controller.isGenerate, false), + ], + ), + ); + } + + Widget _buildModeButton(String label, RxBool isSelected, bool base) { + return Expanded( + child: InkWell( + onTap: () => controller.onCreationTypeChange(base), + child: Obx( + () => Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: isSelected.value == base ? AppColors.primaryBlue : Colors.transparent, + borderRadius: base + ? BorderRadius.only( + topLeft: Radius.circular(10), + bottomLeft: Radius.circular(10), + ) + : BorderRadius.only( + topRight: Radius.circular(10), + bottomRight: Radius.circular(10), + ), + ), + alignment: Alignment.center, + child: Text( + label, + style: TextStyle( + color: isSelected.value == base ? Colors.white : AppColors.softGrayText, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/feature/register/view/register_page.dart b/lib/feature/register/view/register_page.dart index 0063f4a..e36cbdd 100644 --- a/lib/feature/register/view/register_page.dart +++ b/lib/feature/register/view/register_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; import 'package:quiz_app/component/app_name.dart'; import 'package:quiz_app/component/global_button.dart'; import 'package:quiz_app/component/global_text_field.dart'; @@ -11,7 +12,7 @@ class RegisterView extends GetView { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: const Color(0xFFFAFBFC), + backgroundColor: AppColors.background, body: SafeArea( child: Padding( padding: const EdgeInsets.all(16.0), diff --git a/lib/feature/splash_screen/presentation/splash_screen_page.dart b/lib/feature/splash_screen/presentation/splash_screen_page.dart index eec56b5..e1d9101 100644 --- a/lib/feature/splash_screen/presentation/splash_screen_page.dart +++ b/lib/feature/splash_screen/presentation/splash_screen_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/component/app_name.dart'; import 'package:quiz_app/data/services/user_storage_service.dart'; @@ -29,7 +30,7 @@ class SplashScreenView extends StatelessWidget { }); return const Scaffold( - backgroundColor: Color(0xFFFAFBFC), + backgroundColor: AppColors.background, body: Center( child: AppName(), ), From e801962e4cec7f908411dac9242d37b6c4001ec5 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 27 Apr 2025 13:40:10 +0700 Subject: [PATCH 017/104] feat: logic for quiz input is done --- lib/app/const/enums/question_type.dart | 1 + .../models/quiz/quiestion_data_model.dart | 43 ++++++ .../controller/quiz_creation_controller.dart | 123 +++++++++++++++++- .../component/custom_question_component.dart | 93 ++++++++----- .../component/option_question_component.dart | 64 ++++----- .../component/true_or_false_component.dart | 65 ++++----- .../view/quiz_creation_view.dart | 3 + 7 files changed, 286 insertions(+), 106 deletions(-) create mode 100644 lib/app/const/enums/question_type.dart create mode 100644 lib/data/models/quiz/quiestion_data_model.dart diff --git a/lib/app/const/enums/question_type.dart b/lib/app/const/enums/question_type.dart new file mode 100644 index 0000000..d61ca6f --- /dev/null +++ b/lib/app/const/enums/question_type.dart @@ -0,0 +1 @@ +enum QuestionType { fillTheBlank, option, trueOrFalse } diff --git a/lib/data/models/quiz/quiestion_data_model.dart b/lib/data/models/quiz/quiestion_data_model.dart new file mode 100644 index 0000000..9e37bd7 --- /dev/null +++ b/lib/data/models/quiz/quiestion_data_model.dart @@ -0,0 +1,43 @@ +import 'package:quiz_app/app/const/enums/question_type.dart'; + +class OptionData { + final int index; + final String text; + + OptionData({required this.index, required this.text}); +} + +class QuestionData { + final int index; + final String? question; + final String? answer; + final List? options; + final int? correctAnswerIndex; + final QuestionType? type; + + QuestionData({ + required this.index, + this.question, + this.answer, + this.options, + this.correctAnswerIndex, + this.type, + }); + + QuestionData copyWith({ + String? question, + String? answer, + List? options, + int? correctAnswerIndex, + QuestionType? type, + }) { + return QuestionData( + index: index, + question: question ?? this.question, + answer: answer ?? this.answer, + options: options ?? this.options, + correctAnswerIndex: correctAnswerIndex ?? this.correctAnswerIndex, + type: type ?? this.type, + ); + } +} diff --git a/lib/feature/quiz_creation/controller/quiz_creation_controller.dart b/lib/feature/quiz_creation/controller/quiz_creation_controller.dart index 04623e6..1df9d85 100644 --- a/lib/feature/quiz_creation/controller/quiz_creation_controller.dart +++ b/lib/feature/quiz_creation/controller/quiz_creation_controller.dart @@ -1,17 +1,126 @@ import 'package:flutter/widgets.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/app/const/enums/question_type.dart'; +import 'package:quiz_app/data/models/quiz/quiestion_data_model.dart'; class QuizCreationController extends GetxController { - TextEditingController questionTC = TextEditingController(); - TextEditingController answerTC = TextEditingController(); + final TextEditingController questionTC = TextEditingController(); + final TextEditingController answerTC = TextEditingController(); + final List optionTCList = List.generate(4, (_) => TextEditingController()); + final RxInt selectedOptionIndex = 0.obs; RxBool isGenerate = true.obs; - Rx currentQuestionType = QuestionType.fillTheBlank.obs; + RxList quizData = [QuestionData(index: 1)].obs; + RxInt selectedQuizIndex = 0.obs; - onCreationTypeChange(bool value) => isGenerate.value = value; + @override + void onInit() { + super.onInit(); + _initializeListeners(); + } - onQuestionTypeChange(QuestionType type) => currentQuestionType.value = type; + void _initializeListeners() { + // Listener untuk pertanyaan + questionTC.addListener(() { + if (quizData.isNotEmpty) { + _updateCurrentQuestion(question: questionTC.text); + } + }); + + // Listener untuk jawaban langsung (Fill the Blank atau True/False) + answerTC.addListener(() { + if (quizData.isNotEmpty && currentQuestionType.value != QuestionType.option) { + _updateCurrentQuestion(answer: answerTC.text); + } + }); + + // Listener untuk masing-masing pilihan opsi + for (var i = 0; i < optionTCList.length; i++) { + optionTCList[i].addListener(() { + if (quizData.isNotEmpty && currentQuestionType.value == QuestionType.option) { + _updateCurrentQuestion( + options: List.generate( + optionTCList.length, + (index) => OptionData(index: index, text: optionTCList[index].text), + ), + ); + } + }); + } + + // Listener perubahan tipe soal + ever(currentQuestionType, (type) { + if (quizData.isNotEmpty) { + _updateCurrentQuestion(type: type); + } + }); + + // Listener perubahan jawaban benar (untuk pilihan ganda) + ever(selectedOptionIndex, (index) { + if (quizData.isNotEmpty && currentQuestionType.value == QuestionType.option) { + _updateCurrentQuestion(correctAnswerIndex: index); + } + }); + } + + void onCreationTypeChange(bool value) { + isGenerate.value = value; + } + + void onQuestionTypeChange(QuestionType type) { + currentQuestionType.value = type; + } + + void onQuestionAdd() { + quizData.add(QuestionData(index: quizData.length + 1)); + } + + void onSelectedQuizItem(int index) { + selectedQuizIndex.value = index; + final data = quizData[index]; + + questionTC.text = data.question ?? ""; + answerTC.text = data.answer ?? ""; + currentQuestionType.value = data.type ?? QuestionType.fillTheBlank; + + if (data.options != null && data.options!.isNotEmpty) { + for (int i = 0; i < optionTCList.length; i++) { + optionTCList[i].text = data.options!.length > i ? data.options![i].text : ''; + } + selectedOptionIndex.value = data.correctAnswerIndex ?? 0; + } else { + for (var controller in optionTCList) { + controller.clear(); + } + selectedOptionIndex.value = 0; + } + } + + void _updateCurrentQuestion({ + String? question, + String? answer, + List? options, + int? correctAnswerIndex, + QuestionType? type, + }) { + final current = quizData[selectedQuizIndex.value]; + quizData[selectedQuizIndex.value] = current.copyWith( + question: question, + answer: answer, + options: options ?? + (currentQuestionType.value == QuestionType.option + ? List.generate( + optionTCList.length, + (index) => OptionData(index: index, text: optionTCList[index].text), + ) + : null), + correctAnswerIndex: correctAnswerIndex, + type: type, + ); + } + + void updateTOFAnswer(bool answer) { + _updateCurrentQuestion(answer: answer.toString()); + } } - -enum QuestionType { fillTheBlank, option, trueOrFalse } diff --git a/lib/feature/quiz_creation/view/component/custom_question_component.dart b/lib/feature/quiz_creation/view/component/custom_question_component.dart index 0c02854..2e083d9 100644 --- a/lib/feature/quiz_creation/view/component/custom_question_component.dart +++ b/lib/feature/quiz_creation/view/component/custom_question_component.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/app/const/enums/question_type.dart'; import 'package:quiz_app/feature/quiz_creation/controller/quiz_creation_controller.dart'; import 'package:quiz_app/feature/quiz_creation/view/component/fill_the_blank_component.dart'; import 'package:quiz_app/feature/quiz_creation/view/component/option_question_component.dart'; @@ -33,10 +34,7 @@ class CustomQuestionComponent extends GetView { answerTC: controller.answerTC, ); case QuestionType.option: - return OptionQuestionComponent( - questionTC: TextEditingController(), - optionTCList: List.generate(4, (index) => TextEditingController()), - ); + return OptionQuestionComponent(); case QuestionType.trueOrFalse: return TrueFalseQuestionComponent(questionTC: controller.questionTC); } @@ -44,35 +42,66 @@ class CustomQuestionComponent extends GetView { } Widget _buildNumberPicker() { - return Wrap( - spacing: 8, - runSpacing: 8, - children: List.generate(14, (index) { - return Container( - width: 42, - height: 42, - alignment: Alignment.center, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: AppColors.borderLight), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 4, - offset: const Offset(2, 2), - ), - ], + return Obx( + () => SizedBox( + height: 100, + child: GridView.builder( + scrollDirection: Axis.horizontal, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 1, ), - child: Text( - '${index + 1}', - style: const TextStyle( - fontWeight: FontWeight.w600, - color: AppColors.darkText, - ), - ), - ); - }), + itemCount: controller.quizData.length + 1, + itemBuilder: (context, index) { + final isLast = index == controller.quizData.length; + + return GestureDetector( + onTap: () { + if (isLast) { + controller.onQuestionAdd(); + } else { + controller.onSelectedQuizItem(index); + } + }, + child: Obx(() { + bool isSelected = controller.selectedQuizIndex.value == index; + return Container( + width: 60, + height: 60, + alignment: Alignment.center, + decoration: BoxDecoration( + color: isSelected ? AppColors.primaryBlue : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected ? AppColors.primaryBlue : AppColors.borderLight, + width: 2, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(13), + blurRadius: 4, + offset: const Offset(2, 2), + ), + ], + ), + child: isLast + ? const Icon(Icons.add, color: AppColors.darkText) + : Text( + '${controller.quizData[index].index}', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16, + color: isSelected ? Colors.white : AppColors.darkText, + ), + ), + ); + }), + ); + }, + ), + ), ); } diff --git a/lib/feature/quiz_creation/view/component/option_question_component.dart b/lib/feature/quiz_creation/view/component/option_question_component.dart index 1e46db0..0a95424 100644 --- a/lib/feature/quiz_creation/view/component/option_question_component.dart +++ b/lib/feature/quiz_creation/view/component/option_question_component.dart @@ -1,23 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:get/get.dart'; import 'package:quiz_app/component/global_text_field.dart'; import 'package:quiz_app/component/label_text_field.dart'; +import 'package:quiz_app/feature/quiz_creation/controller/quiz_creation_controller.dart'; // Pastikan import controllermu -class OptionQuestionComponent extends StatefulWidget { - final TextEditingController questionTC; - final List optionTCList; - - const OptionQuestionComponent({ - super.key, - required this.questionTC, - required this.optionTCList, - }); - - @override - State createState() => _OptionQuestionComponentState(); -} - -class _OptionQuestionComponentState extends State { - String? selectedCorrectAnswer; // A, B, C, D +class OptionQuestionComponent extends GetView { + const OptionQuestionComponent({super.key}); @override Widget build(BuildContext context) { @@ -26,19 +14,19 @@ class _OptionQuestionComponentState extends State { // Pertanyaan LabelTextField(label: "Pertanyaan"), GlobalTextField( - controller: widget.questionTC, + controller: controller.questionTC, limitTextLine: 3, hintText: "Tulis Pertanyaan", ), const SizedBox(height: 15), // Pilihan A, B, C, D - ...List.generate(widget.optionTCList.length, (index) { + ...List.generate(controller.optionTCList.length, (index) { return Column( children: [ LabelTextField(label: "Pilihan ${String.fromCharCode(65 + index)}"), GlobalTextField( - controller: widget.optionTCList[index], + controller: controller.optionTCList[index], hintText: "Tulis Pilihan ${String.fromCharCode(65 + index)}", ), const SizedBox(height: 10), @@ -46,8 +34,9 @@ class _OptionQuestionComponentState extends State { ); }), - // Jawaban Benar Dropdown const SizedBox(height: 10), + + // Jawaban Benar Dropdown LabelTextField(label: "Jawaban Benar"), Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), @@ -56,23 +45,24 @@ class _OptionQuestionComponentState extends State { borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.grey), ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: selectedCorrectAnswer, - hint: const Text('Pilih Jawaban Benar'), - isExpanded: true, - items: List.generate(widget.optionTCList.length, (index) { - final optionLabel = String.fromCharCode(65 + index); // 'A', 'B', 'C', etc. - return DropdownMenuItem( - value: optionLabel, - child: Text(optionLabel), - ); - }), - onChanged: (value) { - setState(() { - selectedCorrectAnswer = value; - }); - }, + child: Obx( + () => DropdownButtonHideUnderline( + child: DropdownButton( + value: controller.selectedOptionIndex.value, + isExpanded: true, + items: List.generate(controller.optionTCList.length, (index) { + final optionLabel = String.fromCharCode(65 + index); // 'A', 'B', 'C', etc. + return DropdownMenuItem( + value: index, + child: Text(optionLabel), + ); + }), + onChanged: (value) { + if (value != null) { + controller.selectedOptionIndex.value = value; + } + }, + ), ), ), ), diff --git a/lib/feature/quiz_creation/view/component/true_or_false_component.dart b/lib/feature/quiz_creation/view/component/true_or_false_component.dart index 7523842..79f7170 100644 --- a/lib/feature/quiz_creation/view/component/true_or_false_component.dart +++ b/lib/feature/quiz_creation/view/component/true_or_false_component.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:get/get.dart'; import 'package:quiz_app/component/global_text_field.dart'; import 'package:quiz_app/component/label_text_field.dart'; +import 'package:quiz_app/feature/quiz_creation/controller/quiz_creation_controller.dart'; // Ganti path sesuai projekmu -class TrueFalseQuestionComponent extends StatefulWidget { +class TrueFalseQuestionComponent extends GetView { final TextEditingController questionTC; const TrueFalseQuestionComponent({ @@ -10,13 +12,6 @@ class TrueFalseQuestionComponent extends StatefulWidget { required this.questionTC, }); - @override - State createState() => _TrueFalseQuestionComponentState(); -} - -class _TrueFalseQuestionComponentState extends State { - bool? selectedAnswer; // true or false - @override Widget build(BuildContext context) { return Column( @@ -24,13 +19,13 @@ class _TrueFalseQuestionComponentState extends State // Pertanyaan LabelTextField(label: "Pertanyaan"), GlobalTextField( - controller: widget.questionTC, + controller: questionTC, limitTextLine: 3, hintText: "Tulis Pertanyaan", ), const SizedBox(height: 15), - // Jawaban Dropdown + // Jawaban Benar Dropdown LabelTextField(label: "Jawaban Benar"), Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), @@ -39,26 +34,28 @@ class _TrueFalseQuestionComponentState extends State borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.grey), ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: selectedAnswer, - hint: const Text('Pilih Jawaban Benar'), - isExpanded: true, - items: const [ - DropdownMenuItem( - value: true, - child: Text('True'), - ), - DropdownMenuItem( - value: false, - child: Text('False'), - ), - ], - onChanged: (value) { - setState(() { - selectedAnswer = value; - }); - }, + child: Obx( + () => DropdownButtonHideUnderline( + child: DropdownButton( + value: _getCurrentAnswer(), + hint: const Text('Pilih Jawaban Benar'), + isExpanded: true, + items: const [ + DropdownMenuItem( + value: true, + child: Text('True'), + ), + DropdownMenuItem( + value: false, + child: Text('False'), + ), + ], + onChanged: (value) { + if (value != null) { + controller.updateTOFAnswer(value); + } + }, + ), ), ), ), @@ -67,4 +64,12 @@ class _TrueFalseQuestionComponentState extends State ], ); } + + bool? _getCurrentAnswer() { + // Ambil answer dari controller dan parsing ke bool + final currentAnswer = controller.quizData[controller.selectedQuizIndex.value].answer; + if (currentAnswer == "true") return true; + if (currentAnswer == "false") return false; + return null; + } } diff --git a/lib/feature/quiz_creation/view/quiz_creation_view.dart b/lib/feature/quiz_creation/view/quiz_creation_view.dart index eede0b6..6c2076c 100644 --- a/lib/feature/quiz_creation/view/quiz_creation_view.dart +++ b/lib/feature/quiz_creation/view/quiz_creation_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/component/global_button.dart'; import 'package:quiz_app/feature/quiz_creation/controller/quiz_creation_controller.dart'; import 'package:quiz_app/feature/quiz_creation/view/component/custom_question_component.dart'; import 'package:quiz_app/feature/quiz_creation/view/component/generate_component.dart'; @@ -40,6 +41,8 @@ class QuizCreationView extends GetView { Obx( () => controller.isGenerate.value ? GenerateComponent() : CustomQuestionComponent(), ), + const SizedBox(height: 30), + GlobalButton(text: "simpan semua", onPressed: () {}) ], ), ), From 9db744679d9272bd4dfb213e5101df7c822948a7 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 27 Apr 2025 14:16:15 +0700 Subject: [PATCH 018/104] feat: done on quiz preview --- lib/app/routes/app_pages.dart | 7 + lib/app/routes/app_routes.dart | 1 + .../controller/quiz_creation_controller.dart | 5 + .../view/quiz_creation_view.dart | 2 +- .../binding/quiz_preview_binding.dart | 9 + .../controller/quiz_preview_controller.dart | 193 ++++++++++++++++++ .../quiz_preview/view/quiz_preview.dart | 106 ++++++++++ 7 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 lib/feature/quiz_preview/binding/quiz_preview_binding.dart create mode 100644 lib/feature/quiz_preview/controller/quiz_preview_controller.dart create mode 100644 lib/feature/quiz_preview/view/quiz_preview.dart diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index 924c8ab..13d9a90 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -10,6 +10,8 @@ import 'package:quiz_app/feature/navigation/views/navbar_view.dart'; import 'package:quiz_app/feature/profile/binding/profile_binding.dart'; import 'package:quiz_app/feature/quiz_creation/binding/quiz_creation_binding.dart'; import 'package:quiz_app/feature/quiz_creation/view/quiz_creation_view.dart'; +import 'package:quiz_app/feature/quiz_preview/binding/quiz_preview_binding.dart'; +import 'package:quiz_app/feature/quiz_preview/view/quiz_preview.dart'; import 'package:quiz_app/feature/register/binding/register_binding.dart'; import 'package:quiz_app/feature/register/view/register_page.dart'; import 'package:quiz_app/feature/search/binding/search_binding.dart'; @@ -56,5 +58,10 @@ class AppPages { page: () => QuizCreationView(), binding: QuizCreationBinding(), ), + GetPage( + name: AppRoutes.quizPreviewPage, + page: () => QuizPreviewPage(), + binding: QuizPreviewBinding(), + ), ]; } diff --git a/lib/app/routes/app_routes.dart b/lib/app/routes/app_routes.dart index 52906e7..e1f43d9 100644 --- a/lib/app/routes/app_routes.dart +++ b/lib/app/routes/app_routes.dart @@ -9,4 +9,5 @@ abstract class AppRoutes { static const mainPage = '/main'; static const quizCreatePage = "/quiz/creation"; + static const quizPreviewPage = "/quiz/preview"; } diff --git a/lib/feature/quiz_creation/controller/quiz_creation_controller.dart b/lib/feature/quiz_creation/controller/quiz_creation_controller.dart index 1df9d85..fbc5434 100644 --- a/lib/feature/quiz_creation/controller/quiz_creation_controller.dart +++ b/lib/feature/quiz_creation/controller/quiz_creation_controller.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/const/enums/question_type.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/data/models/quiz/quiestion_data_model.dart'; class QuizCreationController extends GetxController { @@ -123,4 +124,8 @@ class QuizCreationController extends GetxController { void updateTOFAnswer(bool answer) { _updateCurrentQuestion(answer: answer.toString()); } + + void onDone() { + Get.toNamed(AppRoutes.quizPreviewPage, arguments: quizData); + } } diff --git a/lib/feature/quiz_creation/view/quiz_creation_view.dart b/lib/feature/quiz_creation/view/quiz_creation_view.dart index 6c2076c..1ef3a01 100644 --- a/lib/feature/quiz_creation/view/quiz_creation_view.dart +++ b/lib/feature/quiz_creation/view/quiz_creation_view.dart @@ -42,7 +42,7 @@ class QuizCreationView extends GetView { () => controller.isGenerate.value ? GenerateComponent() : CustomQuestionComponent(), ), const SizedBox(height: 30), - GlobalButton(text: "simpan semua", onPressed: () {}) + GlobalButton(text: "simpan semua", onPressed: controller.onDone) ], ), ), diff --git a/lib/feature/quiz_preview/binding/quiz_preview_binding.dart b/lib/feature/quiz_preview/binding/quiz_preview_binding.dart new file mode 100644 index 0000000..2403b46 --- /dev/null +++ b/lib/feature/quiz_preview/binding/quiz_preview_binding.dart @@ -0,0 +1,9 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/feature/quiz_preview/controller/quiz_preview_controller.dart'; + +class QuizPreviewBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => QuizPreviewController()); + } +} diff --git a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart new file mode 100644 index 0000000..edcf152 --- /dev/null +++ b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/app/const/enums/question_type.dart'; +import 'package:quiz_app/data/models/quiz/quiestion_data_model.dart'; + +class QuizPreviewController extends GetxController { + final TextEditingController titleController = TextEditingController(); + final TextEditingController descriptionController = TextEditingController(); + + late final List data; + + @override + void onInit() { + super.onInit(); + loadData(); + } + + void loadData() { + if (Get.arguments is List) { + data = Get.arguments as List; + } else { + data = []; // Default aman supaya gak crash + Get.snackbar('Error', 'Data soal tidak ditemukan'); + } + } + + void onSaveQuiz() { + final title = titleController.text.trim(); + final description = descriptionController.text.trim(); + + if (title.isEmpty || description.isEmpty) { + Get.snackbar('Error', 'Judul dan deskripsi tidak boleh kosong!'); + return; + } + + Get.snackbar('Sukses', 'Kuis berhasil disimpan!'); + } + + Widget buildQuestionCard(QuestionData question) { + return Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 20), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.borderLight), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 6, + offset: const Offset(2, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Soal ${question.index}', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: AppColors.darkText)), + const SizedBox(height: 6), + Text( + _mapQuestionTypeToText(question.type), + style: const TextStyle(fontSize: 12, color: AppColors.softGrayText, fontStyle: FontStyle.italic), + ), + const SizedBox(height: 12), + Text( + question.question ?? '-', + style: const TextStyle(fontSize: 16, color: AppColors.darkText), + ), + const SizedBox(height: 16), + _buildAnswerSection(question), + const SizedBox(height: 10), + const Text( + 'Durasi: 0 detik', + style: TextStyle(fontSize: 14, color: AppColors.softGrayText), + ), + ], + ), + ); + } + + Widget _buildAnswerSection(QuestionData question) { + if (question.type == QuestionType.option && question.options != null) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: question.options!.map((option) { + bool isCorrect = question.correctAnswerIndex == option.index; + return Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: isCorrect ? AppColors.primaryBlue.withOpacity(0.1) : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + isCorrect ? Icons.check_circle_rounded : Icons.circle_outlined, + size: 18, + color: isCorrect ? AppColors.primaryBlue : AppColors.softGrayText, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + option.text, + style: TextStyle( + fontWeight: isCorrect ? FontWeight.bold : FontWeight.normal, + color: isCorrect ? AppColors.primaryBlue : AppColors.darkText, + ), + ), + ), + ], + ), + ); + }).toList(), + ); + } else if (question.type == QuestionType.fillTheBlank) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: _buildFillTheBlankPossibilities(question.answer ?? '-'), + ); + } else if (question.type == QuestionType.trueOrFalse) { + return Text( + 'Jawaban: ${question.answer ?? '-'}', + style: const TextStyle(color: AppColors.softGrayText), + ); + } else { + return const SizedBox(); + } + } + + String _mapQuestionTypeToText(QuestionType? type) { + switch (type) { + case QuestionType.option: + return 'Tipe: Pilihan Ganda'; + case QuestionType.fillTheBlank: + return 'Tipe: Isian Kosong'; + case QuestionType.trueOrFalse: + return 'Tipe: Benar / Salah'; + default: + return 'Tipe: Tidak diketahui'; + } + } + + List _buildFillTheBlankPossibilities(String answer) { + List possibilities = [ + _capitalizeEachWord(answer), + answer.toLowerCase(), + _capitalizeFirstWordOnly(answer), + ]; + + return possibilities.map((option) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + const Icon(Icons.arrow_right, size: 18, color: AppColors.softGrayText), + const SizedBox(width: 6), + Text( + option, + style: const TextStyle(color: AppColors.darkText), + ), + ], + ), + ); + }).toList(); + } + + String _capitalizeEachWord(String text) { + return text.split(' ').map((word) { + if (word.isEmpty) return word; + return word[0].toUpperCase() + word.substring(1).toLowerCase(); + }).join(' '); + } + + String _capitalizeFirstWordOnly(String text) { + if (text.isEmpty) return text; + List parts = text.split(' '); + parts[0] = parts[0][0].toUpperCase() + parts[0].substring(1).toLowerCase(); + for (int i = 1; i < parts.length; i++) { + parts[i] = parts[i].toLowerCase(); + } + return parts.join(' '); + } + + @override + void onClose() { + titleController.dispose(); + descriptionController.dispose(); + super.onClose(); + } +} diff --git a/lib/feature/quiz_preview/view/quiz_preview.dart b/lib/feature/quiz_preview/view/quiz_preview.dart new file mode 100644 index 0000000..21fa974 --- /dev/null +++ b/lib/feature/quiz_preview/view/quiz_preview.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/component/global_button.dart'; +import 'package:quiz_app/feature/quiz_preview/controller/quiz_preview_controller.dart'; + +class QuizPreviewPage extends GetView { + const QuizPreviewPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.background, + elevation: 0, + title: const Text( + 'Preview Quiz', + style: TextStyle( + color: AppColors.darkText, + fontWeight: FontWeight.bold, + ), + ), + centerTitle: true, + iconTheme: const IconThemeData(color: AppColors.darkText), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTextField( + label: 'Judul Kuis', + controller: controller.titleController, + ), + const SizedBox(height: 20), + _buildTextField( + label: 'Deskripsi Kuis', + controller: controller.descriptionController, + maxLines: 3, + ), + const SizedBox(height: 30), + const Divider(thickness: 1.2, color: AppColors.borderLight), + const SizedBox(height: 20), + _buildQuestionContent(), + const SizedBox(height: 30), + GlobalButton( + onPressed: controller.onSaveQuiz, + text: "Simpan Kuis", + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildTextField({ + required String label, + required TextEditingController controller, + int maxLines = 1, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(fontWeight: FontWeight.w600, color: AppColors.softGrayText)), + const SizedBox(height: 8), + TextField( + controller: controller, + maxLines: maxLines, + decoration: InputDecoration( + hintText: 'Masukkan $label', + hintStyle: const TextStyle(color: AppColors.softGrayText), + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.borderLight), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.borderLight), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.primaryBlue, width: 2), + ), + ), + ), + ], + ); + } + + Widget _buildQuestionContent() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: controller.data.map((question) { + return controller.buildQuestionCard(question); + }).toList(), + ); + } +} From 01e92c14abebbffe23698937fd911655c790b5e3 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 27 Apr 2025 14:57:26 +0700 Subject: [PATCH 019/104] feat: add default value untuk option answer --- .../controller/quiz_creation_controller.dart | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/feature/quiz_creation/controller/quiz_creation_controller.dart b/lib/feature/quiz_creation/controller/quiz_creation_controller.dart index fbc5434..53be6a7 100644 --- a/lib/feature/quiz_creation/controller/quiz_creation_controller.dart +++ b/lib/feature/quiz_creation/controller/quiz_creation_controller.dart @@ -12,7 +12,7 @@ class QuizCreationController extends GetxController { RxBool isGenerate = true.obs; Rx currentQuestionType = QuestionType.fillTheBlank.obs; - RxList quizData = [QuestionData(index: 1)].obs; + RxList quizData = [QuestionData(index: 1, type: QuestionType.fillTheBlank)].obs; RxInt selectedQuizIndex = 0.obs; @override @@ -53,7 +53,11 @@ class QuizCreationController extends GetxController { // Listener perubahan tipe soal ever(currentQuestionType, (type) { if (quizData.isNotEmpty) { - _updateCurrentQuestion(type: type); + if (type == QuestionType.option) { + _updateCurrentQuestion(type: type, correctAnswerIndex: 0); + } else { + _updateCurrentQuestion(type: type, correctAnswerIndex: null); + } } }); From eaf97e969fd2df83f2143baa30cf0b7f4c710cc0 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 27 Apr 2025 15:06:20 +0700 Subject: [PATCH 020/104] feat: addin pop up confirmation for back --- .../notification/pop_up_confirmation.dart | 84 +++++++++++++++++++ .../controller/quiz_creation_controller.dart | 10 +++ .../view/quiz_creation_view.dart | 2 +- 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 lib/component/notification/pop_up_confirmation.dart diff --git a/lib/component/notification/pop_up_confirmation.dart b/lib/component/notification/pop_up_confirmation.dart new file mode 100644 index 0000000..c069e63 --- /dev/null +++ b/lib/component/notification/pop_up_confirmation.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; + +class AppDialog { + static Future showExitConfirmationDialog(BuildContext context) async { + await showDialog( + context: context, + barrierDismissible: true, + builder: (BuildContext context) { + return Dialog( + backgroundColor: AppColors.background, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Text( + "Keluar tanpa menyimpan?", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.darkText, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + const Text( + "Perubahan yang belum disimpan akan hilang. Anda yakin ingin keluar?", + style: TextStyle( + fontSize: 14, + color: AppColors.softGrayText, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: () => Navigator.pop(context), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: AppColors.primaryBlue, + side: const BorderSide(color: AppColors.primaryBlue), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 14), + ), + child: const Text("Batal"), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: () { + Navigator.pop(context); // Tutup dialog + Navigator.pop(context); // Kembali halaman + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryBlue, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 14), + ), + child: const Text("Keluar"), + ), + ), + ], + ) + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/feature/quiz_creation/controller/quiz_creation_controller.dart b/lib/feature/quiz_creation/controller/quiz_creation_controller.dart index 53be6a7..041912a 100644 --- a/lib/feature/quiz_creation/controller/quiz_creation_controller.dart +++ b/lib/feature/quiz_creation/controller/quiz_creation_controller.dart @@ -1,7 +1,9 @@ +import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/const/enums/question_type.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/component/notification/pop_up_confirmation.dart'; import 'package:quiz_app/data/models/quiz/quiestion_data_model.dart'; class QuizCreationController extends GetxController { @@ -132,4 +134,12 @@ class QuizCreationController extends GetxController { void onDone() { Get.toNamed(AppRoutes.quizPreviewPage, arguments: quizData); } + + void onBack(BuildContext context) { + if (quizData.length <= 1) { + Navigator.pop(context); + } else { + AppDialog.showExitConfirmationDialog(context); + } + } } diff --git a/lib/feature/quiz_creation/view/quiz_creation_view.dart b/lib/feature/quiz_creation/view/quiz_creation_view.dart index 1ef3a01..f99f694 100644 --- a/lib/feature/quiz_creation/view/quiz_creation_view.dart +++ b/lib/feature/quiz_creation/view/quiz_creation_view.dart @@ -25,7 +25,7 @@ class QuizCreationView extends GetView { ), leading: IconButton( icon: const Icon(Icons.arrow_back_ios_new_rounded, color: AppColors.darkText), - onPressed: () => Navigator.pop(context), + onPressed: () => controller.onBack(context), ), centerTitle: true, ), From 261d094d940fbbdeaeb875c6c64365ba4e14c4e7 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 27 Apr 2025 15:19:13 +0700 Subject: [PATCH 021/104] feat: adding index delete on the question maker --- .../notification/delete_confirmation.dart | 69 +++++++++++++++++++ .../models/quiz/quiestion_data_model.dart | 3 +- .../controller/quiz_creation_controller.dart | 25 +++++++ .../component/custom_question_component.dart | 14 ++++ 4 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 lib/component/notification/delete_confirmation.dart diff --git a/lib/component/notification/delete_confirmation.dart b/lib/component/notification/delete_confirmation.dart new file mode 100644 index 0000000..2cad6c2 --- /dev/null +++ b/lib/component/notification/delete_confirmation.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; + +class DeleteQuestionDialog { + static Future show({ + required BuildContext context, + required VoidCallback onDelete, + }) async { + showDialog( + context: context, + barrierDismissible: true, + builder: (context) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + "Hapus Soal?", + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18, color: AppColors.darkText), + ), + const SizedBox(height: 16), + const Text( + "Soal ini akan dihapus dari daftar kuis. Yakin ingin menghapus?", + style: TextStyle(fontSize: 14, color: AppColors.softGrayText), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: () => Navigator.pop(context), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: AppColors.primaryBlue, + side: const BorderSide(color: AppColors.primaryBlue), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: const Text("Batal"), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: () { + Navigator.pop(context); + onDelete(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryBlue, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: const Text("Hapus"), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/data/models/quiz/quiestion_data_model.dart b/lib/data/models/quiz/quiestion_data_model.dart index 9e37bd7..10a7880 100644 --- a/lib/data/models/quiz/quiestion_data_model.dart +++ b/lib/data/models/quiz/quiestion_data_model.dart @@ -25,6 +25,7 @@ class QuestionData { }); QuestionData copyWith({ + int? index, String? question, String? answer, List? options, @@ -32,7 +33,7 @@ class QuestionData { QuestionType? type, }) { return QuestionData( - index: index, + index: index ?? this.index, question: question ?? this.question, answer: answer ?? this.answer, options: options ?? this.options, diff --git a/lib/feature/quiz_creation/controller/quiz_creation_controller.dart b/lib/feature/quiz_creation/controller/quiz_creation_controller.dart index 041912a..dc5462a 100644 --- a/lib/feature/quiz_creation/controller/quiz_creation_controller.dart +++ b/lib/feature/quiz_creation/controller/quiz_creation_controller.dart @@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/const/enums/question_type.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/component/notification/delete_confirmation.dart'; import 'package:quiz_app/component/notification/pop_up_confirmation.dart'; import 'package:quiz_app/data/models/quiz/quiestion_data_model.dart'; @@ -142,4 +143,28 @@ class QuizCreationController extends GetxController { AppDialog.showExitConfirmationDialog(context); } } + + void showDeleteQuestionDialog(BuildContext context, int index) { + DeleteQuestionDialog.show(context: context, onDelete: () => onQuestionDelete(index)); + } + + void onQuestionDelete(int index) { + if (quizData.length <= 1) { + Get.snackbar('Tidak Bisa Dihapus', 'Minimal harus ada satu soal dalam kuis.'); + return; + } + + quizData.removeAt(index); + + for (int i = 0; i < quizData.length; i++) { + quizData[i] = quizData[i].copyWith(index: i + 1); + } + + if (selectedQuizIndex.value == index) { + selectedQuizIndex.value = 0; + onSelectedQuizItem(0); + } else if (selectedQuizIndex.value > index) { + selectedQuizIndex.value -= 1; + } + } } diff --git a/lib/feature/quiz_creation/view/component/custom_question_component.dart b/lib/feature/quiz_creation/view/component/custom_question_component.dart index 2e083d9..18b0ee2 100644 --- a/lib/feature/quiz_creation/view/component/custom_question_component.dart +++ b/lib/feature/quiz_creation/view/component/custom_question_component.dart @@ -15,6 +15,15 @@ class CustomQuestionComponent extends GetView { return Column( children: [ _buildNumberPicker(), + const SizedBox(height: 8), + const Text( + "*Tekan dan tahan soal untuk menghapus", + style: TextStyle( + fontSize: 12, + color: AppColors.softGrayText, + fontStyle: FontStyle.italic, + ), + ), const SizedBox(height: 20), _buildQuizTypeSelector(), const SizedBox(height: 20), @@ -65,6 +74,11 @@ class CustomQuestionComponent extends GetView { controller.onSelectedQuizItem(index); } }, + onLongPress: () { + if (!isLast) { + controller.showDeleteQuestionDialog(context, index); + } + }, child: Obx(() { bool isSelected = controller.selectedQuizIndex.value == index; return Container( From 7a90e7ea1681082af108ba23f7e25ffcdf23f784 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 27 Apr 2025 16:39:42 +0700 Subject: [PATCH 022/104] feat: library view --- lib/app/routes/app_pages.dart | 2 + .../library/binding/library_binding.dart | 9 + .../controller/detail_quiz_controller.dart | 72 ++++++++ .../controller/library_controller.dart | 40 ++++ .../library/view/detail_quix_view.dart | 174 ++++++++++++++++++ lib/feature/library/view/library_view.dart | 136 ++++++++++++++ lib/feature/navigation/views/navbar_view.dart | 9 +- 7 files changed, 441 insertions(+), 1 deletion(-) create mode 100644 lib/feature/library/binding/library_binding.dart create mode 100644 lib/feature/library/controller/detail_quiz_controller.dart create mode 100644 lib/feature/library/controller/library_controller.dart create mode 100644 lib/feature/library/view/detail_quix_view.dart create mode 100644 lib/feature/library/view/library_view.dart diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index 13d9a90..70bbb1c 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -3,6 +3,7 @@ import 'package:quiz_app/app/middleware/auth_middleware.dart'; import 'package:quiz_app/feature/history/binding/history_binding.dart'; import 'package:quiz_app/feature/home/binding/home_binding.dart'; import 'package:quiz_app/feature/home/view/home_page.dart'; +import 'package:quiz_app/feature/library/binding/library_binding.dart'; import 'package:quiz_app/feature/login/bindings/login_binding.dart'; import 'package:quiz_app/feature/login/view/login_page.dart'; import 'package:quiz_app/feature/navigation/bindings/navigation_binding.dart'; @@ -48,6 +49,7 @@ class AppPages { NavbarBinding(), HomeBinding(), SearchBinding(), + LibraryBinding(), HistoryBinding(), ProfileBinding(), ], diff --git a/lib/feature/library/binding/library_binding.dart b/lib/feature/library/binding/library_binding.dart new file mode 100644 index 0000000..2ff7624 --- /dev/null +++ b/lib/feature/library/binding/library_binding.dart @@ -0,0 +1,9 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/feature/library/controller/library_controller.dart'; + +class LibraryBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => LibraryController()); + } +} diff --git a/lib/feature/library/controller/detail_quiz_controller.dart b/lib/feature/library/controller/detail_quiz_controller.dart new file mode 100644 index 0000000..25856c3 --- /dev/null +++ b/lib/feature/library/controller/detail_quiz_controller.dart @@ -0,0 +1,72 @@ +import 'package:get/get.dart'; + +class DetailQuizController extends GetxController { + Rx quizData = QuizDetailData( + title: "Sejarah Indonesia - Kerajaan Hindu Budha", + description: "Kuis ini membahas kerajaan-kerajaan Hindu Budha di Indonesia seperti Kutai, Sriwijaya, dan Majapahit.", + date: DateTime.parse("2025-04-25 10:00:00"), + isPublic: true, + totalQuiz: 3, + limitDuration: 900, // dalam detik (900 = 15 menit) + questionListings: [ + QuestionListing( + question: "Kerajaan Hindu tertua di Indonesia adalah?", + targetAnswer: "Kutai", + duration: 30, + type: "fill_the_blank", + ), + QuestionListing( + question: "Apakah benar Majapahit mencapai puncak kejayaan pada masa Hayam Wuruk?", + targetAnswer: "Ya", + duration: 30, + type: "true_false", + ), + QuestionListing( + question: "Kerajaan maritim terbesar di Asia Tenggara pada abad ke-7 adalah?", + targetAnswer: "Sriwijaya", + duration: 30, + type: "fill_the_blank", + ), + ], + ).obs; + + @override + void onInit() { + super.onInit(); + // Dummy data sudah di-inject saat controller init + } +} + +class QuizDetailData { + String title; + String description; + DateTime date; + bool isPublic; + int totalQuiz; + int limitDuration; + List questionListings; + + QuizDetailData({ + this.title = '', + this.description = '', + required this.date, + this.isPublic = true, + this.totalQuiz = 0, + this.limitDuration = 0, + this.questionListings = const [], + }); +} + +class QuestionListing { + String question; + String targetAnswer; + int duration; + String type; + + QuestionListing({ + required this.question, + required this.targetAnswer, + required this.duration, + required this.type, + }); +} diff --git a/lib/feature/library/controller/library_controller.dart b/lib/feature/library/controller/library_controller.dart new file mode 100644 index 0000000..eb45563 --- /dev/null +++ b/lib/feature/library/controller/library_controller.dart @@ -0,0 +1,40 @@ +import 'package:get/get.dart'; + +class LibraryController extends GetxController { + RxList> quizList = >[].obs; + + @override + void onInit() { + super.onInit(); + loadDummyQuiz(); + } + + void loadDummyQuiz() { + quizList.assignAll([ + { + "author_id": "user_12345", + "title": "Sejarah Indonesia - Kerajaan Hindu Budha", + "description": "Kuis ini membahas kerajaan-kerajaan Hindu Budha di Indonesia seperti Kutai, Sriwijaya, dan Majapahit.", + "is_public": true, + "date": "2025-04-25 10:00:00", + "total_quiz": 3, + "limit_duration": 900, + }, + // Tambahkan data dummy lain kalau mau + ]); + } + + String formatDuration(int seconds) { + int minutes = seconds ~/ 60; + return '$minutes menit'; + } + + String formatDate(String dateString) { + try { + // DateTime date = DateTime.parse(dateString); + return "19-04-2025"; + } catch (e) { + return '-'; + } + } +} diff --git a/lib/feature/library/view/detail_quix_view.dart b/lib/feature/library/view/detail_quix_view.dart new file mode 100644 index 0000000..57f8578 --- /dev/null +++ b/lib/feature/library/view/detail_quix_view.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/feature/library/controller/detail_quiz_controller.dart'; + +class DetailQuizView extends GetView { + const DetailQuizView({super.key}); + + @override + Widget build(BuildContext context) { + final data = controller.quizData.value; + + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.background, + elevation: 0, + title: const Text( + 'Detail Quiz', + style: TextStyle( + color: AppColors.darkText, + fontWeight: FontWeight.bold, + ), + ), + centerTitle: true, + iconTheme: const IconThemeData(color: AppColors.darkText), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(20), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Section + Text( + data.title, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: AppColors.darkText, + ), + ), + const SizedBox(height: 8), + Text( + data.description, + style: const TextStyle( + fontSize: 14, + color: AppColors.softGrayText, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + const Icon(Icons.calendar_today_rounded, size: 16, color: AppColors.softGrayText), + const SizedBox(width: 6), + Text( + _formatDate(data.date), + style: const TextStyle(fontSize: 12, color: AppColors.softGrayText), + ), + const SizedBox(width: 12), + const Icon(Icons.timer_rounded, size: 16, color: AppColors.softGrayText), + const SizedBox(width: 6), + Text( + '${data.limitDuration ~/ 60} menit', + style: const TextStyle(fontSize: 12, color: AppColors.softGrayText), + ), + ], + ), + const SizedBox(height: 20), + const Divider(thickness: 1.2, color: AppColors.borderLight), + const SizedBox(height: 20), + + // Soal Section + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: data.questionListings.length, + itemBuilder: (context, index) { + final question = data.questionListings[index]; + return _buildQuestionItem(question, index + 1); + }, + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildQuestionItem(QuestionListing question, int index) { + return Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.borderLight), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 6, + offset: const Offset(2, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Soal $index', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: AppColors.darkText, + ), + ), + const SizedBox(height: 8), + Text( + _mapQuestionTypeToText(question.type), + style: const TextStyle( + fontSize: 12, + fontStyle: FontStyle.italic, + color: AppColors.softGrayText, + ), + ), + const SizedBox(height: 12), + Text( + question.question, + style: const TextStyle( + fontSize: 14, + color: AppColors.darkText, + ), + ), + const SizedBox(height: 12), + Text( + 'Jawaban: ${question.targetAnswer}', + style: const TextStyle( + fontSize: 14, + color: AppColors.softGrayText, + ), + ), + const SizedBox(height: 8), + Text( + 'Durasi: ${question.duration} detik', + style: const TextStyle( + fontSize: 12, + color: AppColors.softGrayText, + ), + ), + ], + ), + ); + } + + String _mapQuestionTypeToText(String? type) { + switch (type) { + case 'option': + return 'Pilihan Ganda'; + case 'fill_the_blank': + return 'Isian Kosong'; + case 'true_false': + return 'Benar / Salah'; + default: + return 'Tipe Tidak Diketahui'; + } + } + + String _formatDate(DateTime date) { + return '${date.day}/${date.month}/${date.year}'; + } +} diff --git a/lib/feature/library/view/library_view.dart b/lib/feature/library/view/library_view.dart new file mode 100644 index 0000000..98b60ff --- /dev/null +++ b/lib/feature/library/view/library_view.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/feature/library/controller/library_controller.dart'; + +class LibraryView extends GetView { + const LibraryView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF9FAFB), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Library Soal', + style: TextStyle( + color: Colors.black, + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + const SizedBox(height: 8), + const Text( + "Kumpulan soal-soal kuis yang sudah dibuat untuk dipelajari.", + style: TextStyle( + color: Colors.grey, + fontSize: 14, + ), + ), + const SizedBox(height: 20), + Expanded( + child: Obx( + () => ListView.builder( + itemCount: controller.quizList.length, + itemBuilder: (context, index) { + final quiz = controller.quizList[index]; + return _buildQuizCard(quiz); + }, + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildQuizCard(Map quiz) { + return Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: const Color(0xFF2563EB), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.menu_book_rounded, color: Colors.white), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + quiz['title'] ?? '-', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: Colors.black, + overflow: TextOverflow.ellipsis, + ), + maxLines: 1, + ), + const SizedBox(height: 4), + Text( + quiz['description'] ?? '-', + style: const TextStyle( + color: Colors.grey, + fontSize: 12, + overflow: TextOverflow.ellipsis, + ), + maxLines: 2, + ), + const SizedBox(height: 8), + Row( + children: [ + const Icon(Icons.calendar_today_rounded, size: 14, color: Colors.grey), + const SizedBox(width: 4), + Text( + controller.formatDate(quiz['date']), + style: const TextStyle(fontSize: 12, color: Colors.grey), + ), + const SizedBox(width: 12), + const Icon(Icons.list, size: 14, color: Colors.grey), + const SizedBox(width: 4), + Text( + '${quiz['total_quiz']} Quizzes', + style: const TextStyle(fontSize: 12, color: Colors.grey), + ), + const SizedBox(width: 12), + const Icon(Icons.access_time, size: 14, color: Colors.grey), + const SizedBox(width: 4), + Text( + controller.formatDuration(quiz['limit_duration'] ?? 0), + style: const TextStyle(fontSize: 12, color: Colors.grey), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/feature/navigation/views/navbar_view.dart b/lib/feature/navigation/views/navbar_view.dart index b85ccc7..2f52203 100644 --- a/lib/feature/navigation/views/navbar_view.dart +++ b/lib/feature/navigation/views/navbar_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/feature/history/view/history_view.dart'; import 'package:quiz_app/feature/home/view/home_page.dart'; +import 'package:quiz_app/feature/library/view/library_view.dart'; import 'package:quiz_app/feature/navigation/controllers/navigation_controller.dart'; import 'package:quiz_app/feature/profile/view/profile_view.dart'; import 'package:quiz_app/feature/search/view/search_view.dart'; @@ -19,8 +20,10 @@ class NavbarView extends GetView { case 1: return const SearchView(); case 2: - return const HistoryView(); + return const LibraryView(); case 3: + return const HistoryView(); + case 4: return const ProfileView(); default: return const HomeView(); @@ -40,6 +43,10 @@ class NavbarView extends GetView { icon: Icon(Icons.search), label: 'Search', ), + BottomNavigationBarItem( + icon: Icon(Icons.menu_book), + label: 'Library', + ), BottomNavigationBarItem( icon: Icon(Icons.history), label: 'History', From effaa4cdd7d1dc679a817a60c2c2772a84ba46b5 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 27 Apr 2025 22:21:23 +0700 Subject: [PATCH 023/104] feat: done logic request on the add new quiz --- lib/core/endpoint/api_endpoint.dart | 2 + lib/data/controllers/user_controller.dart | 6 +- .../models/quiz/question_create_request.dart | 65 +++++++++++++ lib/data/providers/dio_client.dart | 33 ++++++- lib/data/services/quiz_service.dart | 32 +++++++ lib/feature/profile/view/profile_view.dart | 3 +- .../binding/quiz_preview_binding.dart | 5 +- .../controller/quiz_preview_controller.dart | 65 ++++++++++++- .../quiz_preview/view/quiz_preview.dart | 92 +++++++++---------- 9 files changed, 250 insertions(+), 53 deletions(-) create mode 100644 lib/data/models/quiz/question_create_request.dart create mode 100644 lib/data/services/quiz_service.dart diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart index 7f975a4..72da61f 100644 --- a/lib/core/endpoint/api_endpoint.dart +++ b/lib/core/endpoint/api_endpoint.dart @@ -5,4 +5,6 @@ class APIEndpoint { static const String loginGoogle = "/login/google"; static const String register = "/register"; + + static const String quiz = "/quiz"; } diff --git a/lib/data/controllers/user_controller.dart b/lib/data/controllers/user_controller.dart index 28a5ef4..5cda711 100644 --- a/lib/data/controllers/user_controller.dart +++ b/lib/data/controllers/user_controller.dart @@ -1,4 +1,5 @@ import 'package:get/get.dart'; +import 'package:quiz_app/core/utils/logger.dart'; import 'package:quiz_app/data/services/user_storage_service.dart'; class UserController extends GetxController { @@ -9,6 +10,7 @@ class UserController extends GetxController { Rx userName = "".obs; Rx userImage = Rx(null); Rx email = "".obs; + String userId = ""; @override void onInit() { @@ -22,7 +24,9 @@ class UserController extends GetxController { userName.value = data.name; userImage.value = data.picUrl; email.value = data.email; - print("Loaded user: ${data.toJson()}"); + userId = data.id ?? ""; + logC.i("user data $userId"); + logC.i("Loaded user: ${data.toJson()}"); } } } diff --git a/lib/data/models/quiz/question_create_request.dart b/lib/data/models/quiz/question_create_request.dart new file mode 100644 index 0000000..b535fe2 --- /dev/null +++ b/lib/data/models/quiz/question_create_request.dart @@ -0,0 +1,65 @@ +class QuizCreateRequestModel { + final String title; + final String description; + final bool isPublic; + final String date; + final int totalQuiz; + final int limitDuration; + final String authorId; + final List questionListings; + + QuizCreateRequestModel({ + required this.title, + required this.description, + required this.isPublic, + required this.date, + required this.totalQuiz, + required this.limitDuration, + required this.authorId, + required this.questionListings, + }); + + Map toJson() { + return { + 'title': title, + 'description': description, + 'is_public': isPublic, + 'date': date, + 'total_quiz': totalQuiz, + 'limit_duration': limitDuration, + 'author_id': authorId, + 'question_listings': questionListings.map((e) => e.toJson()).toList(), + }; + } +} + +class QuestionListing { + final String question; + final String targetAnswer; + final int duration; + final String type; + final List? options; + + QuestionListing({ + required this.question, + required this.targetAnswer, + required this.duration, + required this.type, + this.options, + }); + + Map toJson() { + final map = { + 'question': question, + 'target_answer': targetAnswer, + 'duration': duration, + 'type': type, + }; + + if (options != null && options!.isNotEmpty) { + map['options'] = options; + } + + return map; + } +} diff --git a/lib/data/providers/dio_client.dart b/lib/data/providers/dio_client.dart index c92b958..11795fc 100644 --- a/lib/data/providers/dio_client.dart +++ b/lib/data/providers/dio_client.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:get/get.dart'; import 'package:quiz_app/core/endpoint/api_endpoint.dart'; +import 'package:quiz_app/core/utils/logger.dart'; class ApiClient extends GetxService { late final Dio dio; @@ -15,7 +16,37 @@ class ApiClient extends GetxService { }, )); - dio.interceptors.add(LogInterceptor(responseBody: true)); + dio.interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) { + logC.i(''' +➡️ [REQUEST] +[${options.method}] ${options.uri} +Headers: ${options.headers} +Body: ${options.data} +'''); + return handler.next(options); + }, + onResponse: (response, handler) { + logC.i(''' +✅ [RESPONSE] +[${response.statusCode}] ${response.requestOptions.uri} +Data: ${response.data} +'''); + return handler.next(response); + }, + onError: (DioException e, handler) { + logC.e(''' +❌ [ERROR] +[${e.response?.statusCode}] ${e.requestOptions.uri} +Message: ${e.message} +Error Data: ${e.response?.data} +'''); + return handler.next(e); + }, + ), + ); + return this; } } diff --git a/lib/data/services/quiz_service.dart b/lib/data/services/quiz_service.dart new file mode 100644 index 0000000..7067a9e --- /dev/null +++ b/lib/data/services/quiz_service.dart @@ -0,0 +1,32 @@ +import 'package:dio/dio.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/core/endpoint/api_endpoint.dart'; +import 'package:quiz_app/data/models/quiz/question_create_request.dart'; +import 'package:quiz_app/data/providers/dio_client.dart'; + +class QuizService extends GetxService { + late final Dio _dio; + + @override + void onInit() { + _dio = Get.find().dio; + super.onInit(); + } + + Future createQuiz(QuizCreateRequestModel request) async { + try { + final response = await _dio.post( + APIEndpoint.quiz, + data: request.toJson(), + ); + + if (response.statusCode == 201) { + return true; + } else { + throw Exception("Quiz creation failed"); + } + } catch (e) { + throw Exception("Quiz creation error: $e"); + } + } +} diff --git a/lib/feature/profile/view/profile_view.dart b/lib/feature/profile/view/profile_view.dart index cfc0cd0..a911402 100644 --- a/lib/feature/profile/view/profile_view.dart +++ b/lib/feature/profile/view/profile_view.dart @@ -13,8 +13,7 @@ class ProfileView extends GetView { child: Padding( padding: const EdgeInsets.all(20), child: Obx(() { - return Column( - crossAxisAlignment: CrossAxisAlignment.center, + return ListView( children: [ const SizedBox(height: 20), _buildAvatar(), diff --git a/lib/feature/quiz_preview/binding/quiz_preview_binding.dart b/lib/feature/quiz_preview/binding/quiz_preview_binding.dart index 2403b46..7d3c4d7 100644 --- a/lib/feature/quiz_preview/binding/quiz_preview_binding.dart +++ b/lib/feature/quiz_preview/binding/quiz_preview_binding.dart @@ -1,9 +1,12 @@ import 'package:get/get.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/services/quiz_service.dart'; import 'package:quiz_app/feature/quiz_preview/controller/quiz_preview_controller.dart'; class QuizPreviewBinding extends Bindings { @override void dependencies() { - Get.lazyPut(() => QuizPreviewController()); + Get.lazyPut(() => QuizService()); + Get.lazyPut(() => QuizPreviewController(Get.find(), Get.find())); } } diff --git a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart index edcf152..512cc01 100644 --- a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart +++ b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart @@ -2,12 +2,24 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; import 'package:quiz_app/app/const/enums/question_type.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/core/utils/logger.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/models/quiz/question_create_request.dart'; import 'package:quiz_app/data/models/quiz/quiestion_data_model.dart'; +import 'package:quiz_app/data/services/quiz_service.dart'; class QuizPreviewController extends GetxController { final TextEditingController titleController = TextEditingController(); final TextEditingController descriptionController = TextEditingController(); + final QuizService _quizService; + final UserController _userController; + + QuizPreviewController(this._quizService, this._userController); + + RxBool isPublic = false.obs; + late final List data; @override @@ -25,7 +37,7 @@ class QuizPreviewController extends GetxController { } } - void onSaveQuiz() { + Future onSaveQuiz() async { final title = titleController.text.trim(); final description = descriptionController.text.trim(); @@ -34,7 +46,56 @@ class QuizPreviewController extends GetxController { return; } - Get.snackbar('Sukses', 'Kuis berhasil disimpan!'); + try { + final now = DateTime.now(); + final String formattedDate = "${now.day.toString().padLeft(2, '0')}-${now.month.toString().padLeft(2, '0')}-${now.year}"; + + final quizRequest = QuizCreateRequestModel( + title: title, + description: description, + isPublic: isPublic.value, + date: formattedDate, + totalQuiz: data.length, + limitDuration: data.length * 30, + authorId: _userController.userId, + questionListings: _mapQuestionsToListings(data), + ); + final success = await _quizService.createQuiz(quizRequest); + + if (success) { + Get.snackbar('Sukses', 'Kuis berhasil disimpan!'); + Get.offAllNamed(AppRoutes.mainPage); + } + } catch (e) { + logC.e(e); + } + } + + List _mapQuestionsToListings(List questions) { + return questions.map((q) { + String typeString; + switch (q.type) { + case QuestionType.fillTheBlank: + typeString = 'fill_the_blank'; + break; + case QuestionType.option: + typeString = 'option'; + break; + case QuestionType.trueOrFalse: + typeString = 'true_false'; + break; + default: + typeString = 'fill_the_blank'; + } + + return QuestionListing( + question: q.question ?? '', + targetAnswer: q.answer ?? '', + duration: 30, + type: typeString, + options: q.options?.map((o) => o.text).toList(), + ); + }).toList(); } Widget buildQuestionCard(QuestionData question) { diff --git a/lib/feature/quiz_preview/view/quiz_preview.dart b/lib/feature/quiz_preview/view/quiz_preview.dart index 21fa974..48b721c 100644 --- a/lib/feature/quiz_preview/view/quiz_preview.dart +++ b/lib/feature/quiz_preview/view/quiz_preview.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; import 'package:quiz_app/component/global_button.dart'; +import 'package:quiz_app/component/global_text_field.dart'; +import 'package:quiz_app/component/label_text_field.dart'; import 'package:quiz_app/feature/quiz_preview/controller/quiz_preview_controller.dart'; class QuizPreviewPage extends GetView { @@ -31,16 +33,13 @@ class QuizPreviewPage extends GetView { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildTextField( - label: 'Judul Kuis', - controller: controller.titleController, - ), + LabelTextField(label: "Judul"), + GlobalTextField(controller: controller.titleController), const SizedBox(height: 20), - _buildTextField( - label: 'Deskripsi Kuis', - controller: controller.descriptionController, - maxLines: 3, - ), + LabelTextField(label: "Deskripsi Singkat"), + GlobalTextField(controller: controller.descriptionController), + const SizedBox(height: 20), + _buildPublicCheckbox(), // Ganti ke sini const SizedBox(height: 30), const Divider(thickness: 1.2, color: AppColors.borderLight), const SizedBox(height: 20), @@ -58,43 +57,6 @@ class QuizPreviewPage extends GetView { ); } - Widget _buildTextField({ - required String label, - required TextEditingController controller, - int maxLines = 1, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: const TextStyle(fontWeight: FontWeight.w600, color: AppColors.softGrayText)), - const SizedBox(height: 8), - TextField( - controller: controller, - maxLines: maxLines, - decoration: InputDecoration( - hintText: 'Masukkan $label', - hintStyle: const TextStyle(color: AppColors.softGrayText), - filled: true, - fillColor: Colors.white, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: AppColors.borderLight), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: AppColors.borderLight), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: AppColors.primaryBlue, width: 2), - ), - ), - ), - ], - ); - } - Widget _buildQuestionContent() { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -103,4 +65,42 @@ class QuizPreviewPage extends GetView { }).toList(), ); } + + Widget _buildPublicCheckbox() { + return Obx( + () => GestureDetector( + onTap: () { + controller.isPublic.toggle(); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Checkbox( + value: controller.isPublic.value, + activeColor: AppColors.primaryBlue, // Pakai warna biru utama + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + side: const BorderSide( + color: AppColors.primaryBlue, // Pinggirannya juga biru + width: 2, + ), + onChanged: (value) { + controller.isPublic.value = value ?? false; + }, + ), + const SizedBox(width: 8), + const Text( + "Buat Kuis Public", + style: TextStyle( + fontSize: 16, + color: AppColors.darkText, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } } From 6adcb2e47172a99273212cbc15d269a1eeef9977 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 27 Apr 2025 22:26:53 +0700 Subject: [PATCH 024/104] feat: navigasi with param --- .../navigation/controllers/navigation_controller.dart | 9 +++++++++ .../quiz_preview/controller/quiz_preview_controller.dart | 2 +- lib/main.dart | 8 +++++++- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/lib/feature/navigation/controllers/navigation_controller.dart b/lib/feature/navigation/controllers/navigation_controller.dart index 96ade1c..a281e5f 100644 --- a/lib/feature/navigation/controllers/navigation_controller.dart +++ b/lib/feature/navigation/controllers/navigation_controller.dart @@ -3,6 +3,15 @@ import 'package:get/get.dart'; class NavigationController extends GetxController { RxInt selectedIndex = 0.obs; + @override + void onInit() { + super.onInit(); + final args = Get.arguments; + if (args != null && args is int) { + selectedIndex.value = args; + } + } + void changePage(int page) { selectedIndex.value = page; } diff --git a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart index 512cc01..e2d2f90 100644 --- a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart +++ b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart @@ -64,7 +64,7 @@ class QuizPreviewController extends GetxController { if (success) { Get.snackbar('Sukses', 'Kuis berhasil disimpan!'); - Get.offAllNamed(AppRoutes.mainPage); + Get.offAllNamed(AppRoutes.mainPage, arguments: 2); } } catch (e) { logC.e(e); diff --git a/lib/main.dart b/lib/main.dart index f1c8c13..c62a826 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,13 +1,19 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:quiz_app/app/app.dart'; import 'package:quiz_app/core/utils/logger.dart'; void main() { - runZonedGuarded(() { + runZonedGuarded(() async { WidgetsFlutterBinding.ensureInitialized(); + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + runApp(MyApp()); }, (e, stackTrace) { logC.e("issue message $e || $stackTrace"); From e4ac170a2133e7c914567b161c54200758ab16e0 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Mon, 28 Apr 2025 13:04:29 +0700 Subject: [PATCH 025/104] fix: login id not registered --- lib/data/controllers/user_controller.dart | 18 +++-- lib/data/entity/user/user_entity.dart | 35 ++++++++++ .../models/login/login_response_model.dart | 4 +- lib/data/services/user_storage_service.dart | 8 +-- lib/feature/login/bindings/login_binding.dart | 3 +- .../login/controllers/login_controller.dart | 65 +++++++++++-------- lib/feature/profile/view/profile_view.dart | 2 +- .../controller/quiz_preview_controller.dart | 2 +- 8 files changed, 97 insertions(+), 40 deletions(-) create mode 100644 lib/data/entity/user/user_entity.dart diff --git a/lib/data/controllers/user_controller.dart b/lib/data/controllers/user_controller.dart index 5cda711..35d5d4b 100644 --- a/lib/data/controllers/user_controller.dart +++ b/lib/data/controllers/user_controller.dart @@ -1,5 +1,7 @@ -import 'package:get/get.dart'; +import 'package:get/get_rx/src/rx_types/rx_types.dart'; +import 'package:get/get_state_manager/src/simple/get_controllers.dart'; import 'package:quiz_app/core/utils/logger.dart'; +import 'package:quiz_app/data/entity/user/user_entity.dart'; import 'package:quiz_app/data/services/user_storage_service.dart'; class UserController extends GetxController { @@ -10,7 +12,8 @@ class UserController extends GetxController { Rx userName = "".obs; Rx userImage = Rx(null); Rx email = "".obs; - String userId = ""; + + UserEntity? userData; @override void onInit() { @@ -21,12 +24,19 @@ class UserController extends GetxController { Future loadUser() async { final data = await _userStorageService.loadUser(); if (data != null) { + userData = data; userName.value = data.name; userImage.value = data.picUrl; email.value = data.email; - userId = data.id ?? ""; - logC.i("user data $userId"); logC.i("Loaded user: ${data.toJson()}"); } } + + void setUserFromEntity(UserEntity data) { + final userEntity = data; + userData = userEntity; + userName.value = userEntity.name; + userImage.value = userEntity.picUrl; + email.value = userEntity.email; + } } diff --git a/lib/data/entity/user/user_entity.dart b/lib/data/entity/user/user_entity.dart new file mode 100644 index 0000000..7528448 --- /dev/null +++ b/lib/data/entity/user/user_entity.dart @@ -0,0 +1,35 @@ +class UserEntity { + final String id; + final String name; + final String email; + final String? picUrl; + final String? locale; + +UserEntity({ + required this.id, + required this.name, + required this.email, + this.picUrl, + this.locale, + }); + + factory UserEntity.fromJson(Map json) { + return UserEntity( + id: json['id'], + name: json['name'], + email: json['email'], + picUrl: json['pic_url'], + locale: json['locale'], + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'email': email, + 'pic_url': picUrl, + 'locale': locale, + }; + } +} diff --git a/lib/data/models/login/login_response_model.dart b/lib/data/models/login/login_response_model.dart index 0e2a110..7d10c6a 100644 --- a/lib/data/models/login/login_response_model.dart +++ b/lib/data/models/login/login_response_model.dart @@ -25,7 +25,7 @@ class LoginResponseModel { factory LoginResponseModel.fromJson(Map json) { return LoginResponseModel( - id: json['_id'], + id: json['id'], googleId: json['google_id'], email: json['email'], name: json['name'], @@ -40,7 +40,7 @@ class LoginResponseModel { Map toJson() { return { - '_id': id, + 'id': id, 'google_id': googleId, 'email': email, 'name': name, diff --git a/lib/data/services/user_storage_service.dart b/lib/data/services/user_storage_service.dart index 71e599e..a806829 100644 --- a/lib/data/services/user_storage_service.dart +++ b/lib/data/services/user_storage_service.dart @@ -1,22 +1,22 @@ import 'dart:convert'; -import 'package:quiz_app/data/models/login/login_response_model.dart'; +import 'package:quiz_app/data/entity/user/user_entity.dart'; import 'package:shared_preferences/shared_preferences.dart'; class UserStorageService { static const _userKey = 'user_data'; bool isLogged = false; - Future saveUser(LoginResponseModel user) async { + Future saveUser(UserEntity user) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_userKey, jsonEncode(user.toJson())); } - Future loadUser() async { + Future loadUser() async { final prefs = await SharedPreferences.getInstance(); final jsonString = prefs.getString(_userKey); if (jsonString == null) return null; - return LoginResponseModel.fromJson(jsonDecode(jsonString)); + return UserEntity.fromJson(jsonDecode(jsonString)); } Future clearUser() async { diff --git a/lib/feature/login/bindings/login_binding.dart b/lib/feature/login/bindings/login_binding.dart index dce6efd..6c7e36f 100644 --- a/lib/feature/login/bindings/login_binding.dart +++ b/lib/feature/login/bindings/login_binding.dart @@ -1,5 +1,6 @@ import 'package:get/get_core/get_core.dart'; import 'package:get/get_instance/get_instance.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/services/auth_service.dart'; import 'package:quiz_app/data/services/user_storage_service.dart'; import 'package:quiz_app/feature/login/controllers/login_controller.dart'; @@ -8,6 +9,6 @@ class LoginBinding extends Bindings { @override void dependencies() { Get.lazyPut(() => AuthService()); - Get.lazyPut(() => LoginController(Get.find(), Get.find())); + Get.lazyPut(() => LoginController(Get.find(), Get.find(), Get.find())); } } diff --git a/lib/feature/login/controllers/login_controller.dart b/lib/feature/login/controllers/login_controller.dart index c052869..07f3e2d 100644 --- a/lib/feature/login/controllers/login_controller.dart +++ b/lib/feature/login/controllers/login_controller.dart @@ -1,9 +1,11 @@ +import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:google_sign_in/google_sign_in.dart'; -import 'package:flutter/material.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/component/global_button.dart'; import 'package:quiz_app/core/utils/logger.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/entity/user/user_entity.dart'; import 'package:quiz_app/data/models/login/login_request_model.dart'; import 'package:quiz_app/data/models/login/login_response_model.dart'; import 'package:quiz_app/data/services/auth_service.dart'; @@ -12,16 +14,16 @@ import 'package:quiz_app/data/services/user_storage_service.dart'; class LoginController extends GetxController { final AuthService _authService; final UserStorageService _userStorageService; + final UserController _userController; - LoginController(this._authService, this._userStorageService); + LoginController(this._authService, this._userStorageService, this._userController); final TextEditingController emailController = TextEditingController(); final TextEditingController passwordController = TextEditingController(); final Rx isButtonEnabled = ButtonType.disabled.obs; - - var isPasswordHidden = true.obs; - var isLoading = false.obs; + final RxBool isPasswordHidden = true.obs; + final RxBool isLoading = false.obs; final GoogleSignIn _googleSignIn = GoogleSignIn( scopes: ['email', 'profile', 'openid'], @@ -37,22 +39,17 @@ class LoginController extends GetxController { void _validateFields() { final isEmailNotEmpty = emailController.text.trim().isNotEmpty; final isPasswordNotEmpty = passwordController.text.trim().isNotEmpty; - print('its type'); - if (isEmailNotEmpty && isPasswordNotEmpty) { - isButtonEnabled.value = ButtonType.primary; - } else { - isButtonEnabled.value = ButtonType.disabled; - } + isButtonEnabled.value = (isEmailNotEmpty && isPasswordNotEmpty) ? ButtonType.primary : ButtonType.disabled; } void togglePasswordVisibility() { - isPasswordHidden.value = !isPasswordHidden.value; + isPasswordHidden.toggle(); } /// **🔹 Login via Email & Password** Future loginWithEmail() async { - String email = emailController.text.trim(); - String password = passwordController.text.trim(); + final email = emailController.text.trim(); + final password = passwordController.text.trim(); if (email.isEmpty || password.isEmpty) { Get.snackbar("Error", "Email and password are required"); @@ -62,18 +59,17 @@ class LoginController extends GetxController { try { isLoading.value = true; - LoginResponseModel response = await _authService.loginWithEmail( - LoginRequestModel( - email: email, - password: password, - ), + final LoginResponseModel response = await _authService.loginWithEmail( + LoginRequestModel(email: email, password: password), ); - await _userStorageService.saveUser(response); + final userEntity = _convertLoginResponseToUserEntity(response); + await _userStorageService.saveUser(userEntity); + _userController.setUserFromEntity(userEntity); _userStorageService.isLogged = true; - Get.toNamed(AppRoutes.mainPage); + Get.offAllNamed(AppRoutes.mainPage); } catch (e, stackTrace) { logC.e(e, stackTrace: stackTrace); Get.snackbar("Error", "Failed to connect to server"); @@ -82,6 +78,7 @@ class LoginController extends GetxController { } } + /// **🔹 Login via Google** Future loginWithGoogle() async { try { final GoogleSignInAccount? googleUser = await _googleSignIn.signIn(); @@ -91,25 +88,39 @@ class LoginController extends GetxController { } final GoogleSignInAuthentication googleAuth = await googleUser.authentication; + final idToken = googleAuth.idToken; - if (googleAuth.idToken == null || googleAuth.idToken!.isEmpty) { + if (idToken == null || idToken.isEmpty) { Get.snackbar("Error", "Google sign-in failed. No ID Token received."); return; } - String idToken = googleAuth.idToken!; + final LoginResponseModel response = await _authService.loginWithGoogle(idToken); - final response = await _authService.loginWithGoogle(idToken); - await _userStorageService.saveUser(response); + final userEntity = _convertLoginResponseToUserEntity(response); + await _userStorageService.saveUser(userEntity); + _userController.setUserFromEntity(userEntity); _userStorageService.isLogged = true; - Get.toNamed(AppRoutes.mainPage); + Get.offAllNamed(AppRoutes.mainPage); } catch (e, stackTrace) { logC.e("Google Sign-In Error: $e", stackTrace: stackTrace); Get.snackbar("Error", "Google sign-in error"); } } - void goToRegsPage() => Get.toNamed(AppRoutes.mainPage); + void goToRegsPage() => Get.toNamed(AppRoutes.registerPage); + + /// Helper untuk convert LoginResponseModel ke UserEntity + UserEntity _convertLoginResponseToUserEntity(LoginResponseModel response) { + logC.i("user id : ${response.id}"); + return UserEntity( + id: response.id ?? '', + name: response.name, + email: response.email, + picUrl: response.picUrl, + locale: response.locale, + ); + } } diff --git a/lib/feature/profile/view/profile_view.dart b/lib/feature/profile/view/profile_view.dart index a911402..da67a4d 100644 --- a/lib/feature/profile/view/profile_view.dart +++ b/lib/feature/profile/view/profile_view.dart @@ -13,7 +13,7 @@ class ProfileView extends GetView { child: Padding( padding: const EdgeInsets.all(20), child: Obx(() { - return ListView( + return Column( children: [ const SizedBox(height: 20), _buildAvatar(), diff --git a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart index e2d2f90..e37d6d5 100644 --- a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart +++ b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart @@ -57,7 +57,7 @@ class QuizPreviewController extends GetxController { date: formattedDate, totalQuiz: data.length, limitDuration: data.length * 30, - authorId: _userController.userId, + authorId: _userController.userData!.id, questionListings: _mapQuestionsToListings(data), ); final success = await _quizService.createQuiz(quizRequest); From 5b1f579b1313275b1b3838151d49060bd402e11b Mon Sep 17 00:00:00 2001 From: akhdanre Date: Mon, 28 Apr 2025 14:48:43 +0700 Subject: [PATCH 026/104] feat: working on library data from network done --- lib/core/endpoint/api_endpoint.dart | 1 + lib/data/models/base/base_model.dart | 38 ++++++++- lib/data/models/quiz/library_quiz_model.dart | 83 +++++++++++++++++++ lib/data/services/quiz_service.dart | 20 +++++ .../library/binding/library_binding.dart | 6 +- .../controller/library_controller.dart | 44 ++++++---- lib/feature/library/view/library_view.dart | 50 ++++++++--- .../controller/quiz_preview_controller.dart | 7 +- 8 files changed, 216 insertions(+), 33 deletions(-) create mode 100644 lib/data/models/quiz/library_quiz_model.dart diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart index 72da61f..92f5238 100644 --- a/lib/core/endpoint/api_endpoint.dart +++ b/lib/core/endpoint/api_endpoint.dart @@ -7,4 +7,5 @@ class APIEndpoint { static const String register = "/register"; static const String quiz = "/quiz"; + static const String userQuiz = "/quiz/user"; } diff --git a/lib/data/models/base/base_model.dart b/lib/data/models/base/base_model.dart index 9e1e6ea..d5aac4e 100644 --- a/lib/data/models/base/base_model.dart +++ b/lib/data/models/base/base_model.dart @@ -1,7 +1,7 @@ class BaseResponseModel { final String message; final T? data; - final dynamic meta; + final MetaModel? meta; BaseResponseModel({ required this.message, @@ -11,12 +11,44 @@ class BaseResponseModel { factory BaseResponseModel.fromJson( Map json, - T Function(Map) fromJsonT, + T Function(dynamic) fromJsonT, ) { return BaseResponseModel( message: json['message'], data: json['data'] != null ? fromJsonT(json['data']) : null, - meta: json['meta'], + meta: json['meta'] != null ? MetaModel.fromJson(json['meta']) : null, ); } } + +class MetaModel { + final int totalPage; + final int currentPage; + final int totalData; + final int totalAllData; + + MetaModel({ + required this.totalPage, + required this.currentPage, + required this.totalData, + required this.totalAllData, + }); + + factory MetaModel.fromJson(Map json) { + return MetaModel( + totalPage: json['total_page'], + currentPage: json['current_page'], + totalData: json['total_data'], + totalAllData: json['total_all_data'], + ); + } + + Map toJson() { + return { + 'total_page': totalPage, + 'current_page': currentPage, + 'total_data': totalData, + 'total_all_data': totalAllData, + }; + } +} diff --git a/lib/data/models/quiz/library_quiz_model.dart b/lib/data/models/quiz/library_quiz_model.dart new file mode 100644 index 0000000..8dc601e --- /dev/null +++ b/lib/data/models/quiz/library_quiz_model.dart @@ -0,0 +1,83 @@ +class QuizData { + final String authorId; + final String title; + final String? description; + final bool isPublic; + final String? date; + final int totalQuiz; + final int limitDuration; + final List questionListings; + + QuizData({ + required this.authorId, + required this.title, + this.description, + required this.isPublic, + this.date, + required this.totalQuiz, + required this.limitDuration, + required this.questionListings, + }); + + factory QuizData.fromJson(Map json) { + return QuizData( + authorId: json['author_id'], + title: json['title'], + description: json['description'], + isPublic: json['is_public'], + date: json['date'], + totalQuiz: json['total_quiz'], + limitDuration: json['limit_duration'], + questionListings: (json['question_listings'] as List).map((e) => QuestionListing.fromJson(e)).toList(), + ); + } + + Map toJson() { + return { + 'author_id': authorId, + 'title': title, + 'description': description, + 'is_public': isPublic, + 'date': date, + 'total_quiz': totalQuiz, + 'limit_duration': limitDuration, + 'question_listings': questionListings.map((e) => e.toJson()).toList(), + }; + } +} + +class QuestionListing { + final String question; + final String targetAnswer; + final int duration; + final String type; + final List? options; + + QuestionListing({ + required this.question, + required this.targetAnswer, + required this.duration, + required this.type, + this.options, + }); + + factory QuestionListing.fromJson(Map json) { + return QuestionListing( + question: json['question'], + targetAnswer: json['target_answer'], + duration: json['duration'], + type: json['type'], + options: json['options'] != null ? List.from(json['options']) : null, + ); + } + + Map toJson() { + return { + 'question': question, + 'target_answer': targetAnswer, + 'duration': duration, + 'type': type, + 'options': options, + }; + } +} diff --git a/lib/data/services/quiz_service.dart b/lib/data/services/quiz_service.dart index 7067a9e..f1397ba 100644 --- a/lib/data/services/quiz_service.dart +++ b/lib/data/services/quiz_service.dart @@ -1,6 +1,9 @@ import 'package:dio/dio.dart'; import 'package:get/get.dart'; import 'package:quiz_app/core/endpoint/api_endpoint.dart'; +import 'package:quiz_app/core/utils/logger.dart'; +import 'package:quiz_app/data/models/base/base_model.dart'; +import 'package:quiz_app/data/models/quiz/library_quiz_model.dart'; import 'package:quiz_app/data/models/quiz/question_create_request.dart'; import 'package:quiz_app/data/providers/dio_client.dart'; @@ -26,7 +29,24 @@ class QuizService extends GetxService { throw Exception("Quiz creation failed"); } } catch (e) { + logC.e("Quiz creation error: $e"); throw Exception("Quiz creation error: $e"); } } + + Future> userQuiz(String userId, int page) async { + try { + final response = await _dio.get("${APIEndpoint.userQuiz}/$userId?page=$page"); + + final parsedResponse = BaseResponseModel>.fromJson( + response.data, + (data) => (data as List).map((e) => QuizData.fromJson(e as Map)).toList(), + ); + + return parsedResponse.data ?? []; + } catch (e) { + logC.e("Error fetching user quizzes: $e"); + return []; + } + } } diff --git a/lib/feature/library/binding/library_binding.dart b/lib/feature/library/binding/library_binding.dart index 2ff7624..854c38f 100644 --- a/lib/feature/library/binding/library_binding.dart +++ b/lib/feature/library/binding/library_binding.dart @@ -1,9 +1,13 @@ import 'package:get/get.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/services/quiz_service.dart'; import 'package:quiz_app/feature/library/controller/library_controller.dart'; class LibraryBinding extends Bindings { @override void dependencies() { - Get.lazyPut(() => LibraryController()); + Get.lazyPut(() => QuizService()); + + Get.lazyPut(() => LibraryController(Get.find(), Get.find())); } } diff --git a/lib/feature/library/controller/library_controller.dart b/lib/feature/library/controller/library_controller.dart index eb45563..bf24873 100644 --- a/lib/feature/library/controller/library_controller.dart +++ b/lib/feature/library/controller/library_controller.dart @@ -1,27 +1,39 @@ import 'package:get/get.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/models/quiz/library_quiz_model.dart'; +import 'package:quiz_app/data/services/quiz_service.dart'; class LibraryController extends GetxController { - RxList> quizList = >[].obs; + RxList quizs = [].obs; + RxBool isLoading = true.obs; + RxString emptyMessage = "".obs; + + final QuizService _quizService; + final UserController _userController; + LibraryController(this._quizService, this._userController); + + int currentPage = 1; @override void onInit() { + loadUserQuiz(); super.onInit(); - loadDummyQuiz(); } - void loadDummyQuiz() { - quizList.assignAll([ - { - "author_id": "user_12345", - "title": "Sejarah Indonesia - Kerajaan Hindu Budha", - "description": "Kuis ini membahas kerajaan-kerajaan Hindu Budha di Indonesia seperti Kutai, Sriwijaya, dan Majapahit.", - "is_public": true, - "date": "2025-04-25 10:00:00", - "total_quiz": 3, - "limit_duration": 900, - }, - // Tambahkan data dummy lain kalau mau - ]); + void loadUserQuiz() async { + try { + isLoading.value = true; + List data = await _quizService.userQuiz(_userController.userData!.id, currentPage); + if (data.isEmpty) { + emptyMessage.value = "Kamu belum membuat soal."; + } else { + quizs.addAll(data); + } + } catch (e) { + emptyMessage.value = "Terjadi kesalahan saat memuat data."; + } finally { + isLoading.value = false; + } } String formatDuration(int seconds) { @@ -32,7 +44,7 @@ class LibraryController extends GetxController { String formatDate(String dateString) { try { // DateTime date = DateTime.parse(dateString); - return "19-04-2025"; + return "19-04-2025"; // Ini kamu hardcode, pastikan nanti parse bener } catch (e) { return '-'; } diff --git a/lib/feature/library/view/library_view.dart b/lib/feature/library/view/library_view.dart index 98b60ff..11c8da3 100644 --- a/lib/feature/library/view/library_view.dart +++ b/lib/feature/library/view/library_view.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/data/models/quiz/library_quiz_model.dart'; import 'package:quiz_app/feature/library/controller/library_controller.dart'; class LibraryView extends GetView { @@ -33,15 +34,40 @@ class LibraryView extends GetView { ), const SizedBox(height: 20), Expanded( - child: Obx( - () => ListView.builder( - itemCount: controller.quizList.length, + child: Obx(() { + if (controller.isLoading.value) { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 12), + Text( + "Memuat data...", + style: TextStyle(color: Colors.grey, fontSize: 14), + ), + ], + ), + ); + } + + if (controller.quizs.isEmpty) { + return const Center( + child: Text( + "Belum ada soal tersedia.", + style: TextStyle(color: Colors.grey, fontSize: 14), + ), + ); + } + + return ListView.builder( + itemCount: controller.quizs.length, itemBuilder: (context, index) { - final quiz = controller.quizList[index]; + final quiz = controller.quizs[index]; return _buildQuizCard(quiz); }, - ), - ), + ); + }), ), ], ), @@ -50,7 +76,7 @@ class LibraryView extends GetView { ); } - Widget _buildQuizCard(Map quiz) { + Widget _buildQuizCard(QuizData quiz) { return Container( margin: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.all(16), @@ -82,7 +108,7 @@ class LibraryView extends GetView { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - quiz['title'] ?? '-', + quiz.title, style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, @@ -93,7 +119,7 @@ class LibraryView extends GetView { ), const SizedBox(height: 4), Text( - quiz['description'] ?? '-', + quiz.description ?? "", style: const TextStyle( color: Colors.grey, fontSize: 12, @@ -107,21 +133,21 @@ class LibraryView extends GetView { const Icon(Icons.calendar_today_rounded, size: 14, color: Colors.grey), const SizedBox(width: 4), Text( - controller.formatDate(quiz['date']), + controller.formatDate(quiz.date ?? ""), style: const TextStyle(fontSize: 12, color: Colors.grey), ), const SizedBox(width: 12), const Icon(Icons.list, size: 14, color: Colors.grey), const SizedBox(width: 4), Text( - '${quiz['total_quiz']} Quizzes', + '${quiz.totalQuiz} Quizzes', style: const TextStyle(fontSize: 12, color: Colors.grey), ), const SizedBox(width: 12), const Icon(Icons.access_time, size: 14, color: Colors.grey), const SizedBox(width: 4), Text( - controller.formatDuration(quiz['limit_duration'] ?? 0), + controller.formatDuration(quiz.limitDuration), style: const TextStyle(fontSize: 12, color: Colors.grey), ), ], diff --git a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart index e37d6d5..37d16b9 100644 --- a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart +++ b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart @@ -74,23 +74,28 @@ class QuizPreviewController extends GetxController { List _mapQuestionsToListings(List questions) { return questions.map((q) { String typeString; + String answer = ""; switch (q.type) { case QuestionType.fillTheBlank: typeString = 'fill_the_blank'; + answer = q.answer ?? ""; break; case QuestionType.option: typeString = 'option'; + answer = q.correctAnswerIndex.toString(); break; case QuestionType.trueOrFalse: typeString = 'true_false'; + answer = q.answer ?? ""; break; default: typeString = 'fill_the_blank'; + answer = q.answer ?? ""; } return QuestionListing( question: q.question ?? '', - targetAnswer: q.answer ?? '', + targetAnswer: answer, duration: 30, type: typeString, options: q.options?.map((o) => o.text).toList(), From d4d9f0d85d53ccdb3987df2497f5ef3d7f2291c5 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Mon, 28 Apr 2025 15:10:09 +0700 Subject: [PATCH 027/104] feat: done working on quiz app --- lib/app/routes/app_pages.dart | 7 ++ lib/app/routes/app_routes.dart | 2 + lib/data/models/quiz/library_quiz_model.dart | 38 +--------- .../models/quiz/question_create_request.dart | 54 +++++++------- .../models/quiz/question_listings_model.dart | 35 +++++++++ .../library/binding/detail_quiz_binding.dart | 9 +++ .../controller/detail_quiz_controller.dart | 71 ++----------------- .../controller/library_controller.dart | 5 ++ .../library/view/detail_quix_view.dart | 44 ++++++------ lib/feature/library/view/library_view.dart | 2 +- .../controller/quiz_preview_controller.dart | 1 + 11 files changed, 120 insertions(+), 148 deletions(-) create mode 100644 lib/data/models/quiz/question_listings_model.dart create mode 100644 lib/feature/library/binding/detail_quiz_binding.dart diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index 70bbb1c..41296f7 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -3,7 +3,9 @@ import 'package:quiz_app/app/middleware/auth_middleware.dart'; import 'package:quiz_app/feature/history/binding/history_binding.dart'; import 'package:quiz_app/feature/home/binding/home_binding.dart'; import 'package:quiz_app/feature/home/view/home_page.dart'; +import 'package:quiz_app/feature/library/binding/detail_quiz_binding.dart'; import 'package:quiz_app/feature/library/binding/library_binding.dart'; +import 'package:quiz_app/feature/library/view/detail_quix_view.dart'; import 'package:quiz_app/feature/login/bindings/login_binding.dart'; import 'package:quiz_app/feature/login/view/login_page.dart'; import 'package:quiz_app/feature/navigation/bindings/navigation_binding.dart'; @@ -65,5 +67,10 @@ class AppPages { page: () => QuizPreviewPage(), binding: QuizPreviewBinding(), ), + GetPage( + name: AppRoutes.detailQuizPage, + page: () => DetailQuizView(), + binding: DetailQuizBinding(), + ) ]; } diff --git a/lib/app/routes/app_routes.dart b/lib/app/routes/app_routes.dart index e1f43d9..8f896b1 100644 --- a/lib/app/routes/app_routes.dart +++ b/lib/app/routes/app_routes.dart @@ -10,4 +10,6 @@ abstract class AppRoutes { static const quizCreatePage = "/quiz/creation"; static const quizPreviewPage = "/quiz/preview"; + + static const detailQuizPage = "/quiz/detail"; } diff --git a/lib/data/models/quiz/library_quiz_model.dart b/lib/data/models/quiz/library_quiz_model.dart index 8dc601e..e60214c 100644 --- a/lib/data/models/quiz/library_quiz_model.dart +++ b/lib/data/models/quiz/library_quiz_model.dart @@ -1,3 +1,5 @@ +import 'package:quiz_app/data/models/quiz/question_listings_model.dart'; + class QuizData { final String authorId; final String title; @@ -45,39 +47,3 @@ class QuizData { }; } } - -class QuestionListing { - final String question; - final String targetAnswer; - final int duration; - final String type; - final List? options; - - QuestionListing({ - required this.question, - required this.targetAnswer, - required this.duration, - required this.type, - this.options, - }); - - factory QuestionListing.fromJson(Map json) { - return QuestionListing( - question: json['question'], - targetAnswer: json['target_answer'], - duration: json['duration'], - type: json['type'], - options: json['options'] != null ? List.from(json['options']) : null, - ); - } - - Map toJson() { - return { - 'question': question, - 'target_answer': targetAnswer, - 'duration': duration, - 'type': type, - 'options': options, - }; - } -} diff --git a/lib/data/models/quiz/question_create_request.dart b/lib/data/models/quiz/question_create_request.dart index b535fe2..d5d0e34 100644 --- a/lib/data/models/quiz/question_create_request.dart +++ b/lib/data/models/quiz/question_create_request.dart @@ -1,3 +1,5 @@ +import 'package:quiz_app/data/models/quiz/question_listings_model.dart'; + class QuizCreateRequestModel { final String title; final String description; @@ -33,33 +35,33 @@ class QuizCreateRequestModel { } } -class QuestionListing { - final String question; - final String targetAnswer; - final int duration; - final String type; - final List? options; +// class QuestionListing { +// final String question; +// final String targetAnswer; +// final int duration; +// final String type; +// final List? options; - QuestionListing({ - required this.question, - required this.targetAnswer, - required this.duration, - required this.type, - this.options, - }); +// QuestionListing({ +// required this.question, +// required this.targetAnswer, +// required this.duration, +// required this.type, +// this.options, +// }); - Map toJson() { - final map = { - 'question': question, - 'target_answer': targetAnswer, - 'duration': duration, - 'type': type, - }; +// Map toJson() { +// final map = { +// 'question': question, +// 'target_answer': targetAnswer, +// 'duration': duration, +// 'type': type, +// }; - if (options != null && options!.isNotEmpty) { - map['options'] = options; - } +// if (options != null && options!.isNotEmpty) { +// map['options'] = options; +// } - return map; - } -} +// return map; +// } +// } diff --git a/lib/data/models/quiz/question_listings_model.dart b/lib/data/models/quiz/question_listings_model.dart new file mode 100644 index 0000000..0c5a331 --- /dev/null +++ b/lib/data/models/quiz/question_listings_model.dart @@ -0,0 +1,35 @@ +class QuestionListing { + final String question; + final String targetAnswer; + final int duration; + final String type; + final List? options; + + QuestionListing({ + required this.question, + required this.targetAnswer, + required this.duration, + required this.type, + this.options, + }); + + factory QuestionListing.fromJson(Map json) { + return QuestionListing( + question: json['question'], + targetAnswer: json['target_answer'], + duration: json['duration'], + type: json['type'], + options: json['options'] != null ? List.from(json['options']) : null, + ); + } + + Map toJson() { + return { + 'question': question, + 'target_answer': targetAnswer, + 'duration': duration, + 'type': type, + 'options': options, + }; + } +} diff --git a/lib/feature/library/binding/detail_quiz_binding.dart b/lib/feature/library/binding/detail_quiz_binding.dart new file mode 100644 index 0000000..684c18c --- /dev/null +++ b/lib/feature/library/binding/detail_quiz_binding.dart @@ -0,0 +1,9 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/feature/library/controller/detail_quiz_controller.dart'; + +class DetailQuizBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => DetailQuizController()); + } +} diff --git a/lib/feature/library/controller/detail_quiz_controller.dart b/lib/feature/library/controller/detail_quiz_controller.dart index 25856c3..34db30c 100644 --- a/lib/feature/library/controller/detail_quiz_controller.dart +++ b/lib/feature/library/controller/detail_quiz_controller.dart @@ -1,72 +1,15 @@ import 'package:get/get.dart'; +import 'package:quiz_app/data/models/quiz/library_quiz_model.dart'; class DetailQuizController extends GetxController { - Rx quizData = QuizDetailData( - title: "Sejarah Indonesia - Kerajaan Hindu Budha", - description: "Kuis ini membahas kerajaan-kerajaan Hindu Budha di Indonesia seperti Kutai, Sriwijaya, dan Majapahit.", - date: DateTime.parse("2025-04-25 10:00:00"), - isPublic: true, - totalQuiz: 3, - limitDuration: 900, // dalam detik (900 = 15 menit) - questionListings: [ - QuestionListing( - question: "Kerajaan Hindu tertua di Indonesia adalah?", - targetAnswer: "Kutai", - duration: 30, - type: "fill_the_blank", - ), - QuestionListing( - question: "Apakah benar Majapahit mencapai puncak kejayaan pada masa Hayam Wuruk?", - targetAnswer: "Ya", - duration: 30, - type: "true_false", - ), - QuestionListing( - question: "Kerajaan maritim terbesar di Asia Tenggara pada abad ke-7 adalah?", - targetAnswer: "Sriwijaya", - duration: 30, - type: "fill_the_blank", - ), - ], - ).obs; - + late QuizData data; @override void onInit() { + loadData(); super.onInit(); - // Dummy data sudah di-inject saat controller init + } + + void loadData() { + data = Get.arguments as QuizData; } } - -class QuizDetailData { - String title; - String description; - DateTime date; - bool isPublic; - int totalQuiz; - int limitDuration; - List questionListings; - - QuizDetailData({ - this.title = '', - this.description = '', - required this.date, - this.isPublic = true, - this.totalQuiz = 0, - this.limitDuration = 0, - this.questionListings = const [], - }); -} - -class QuestionListing { - String question; - String targetAnswer; - int duration; - String type; - - QuestionListing({ - required this.question, - required this.targetAnswer, - required this.duration, - required this.type, - }); -} diff --git a/lib/feature/library/controller/library_controller.dart b/lib/feature/library/controller/library_controller.dart index bf24873..93d0da5 100644 --- a/lib/feature/library/controller/library_controller.dart +++ b/lib/feature/library/controller/library_controller.dart @@ -1,4 +1,5 @@ import 'package:get/get.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/models/quiz/library_quiz_model.dart'; import 'package:quiz_app/data/services/quiz_service.dart'; @@ -36,6 +37,10 @@ class LibraryController extends GetxController { } } + void goToDetail(int index) { + Get.toNamed(AppRoutes.detailQuizPage, arguments: quizs[index]); + } + String formatDuration(int seconds) { int minutes = seconds ~/ 60; return '$minutes menit'; diff --git a/lib/feature/library/view/detail_quix_view.dart b/lib/feature/library/view/detail_quix_view.dart index 57f8578..36055a5 100644 --- a/lib/feature/library/view/detail_quix_view.dart +++ b/lib/feature/library/view/detail_quix_view.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/component/global_button.dart'; +import 'package:quiz_app/data/models/quiz/question_listings_model.dart'; import 'package:quiz_app/feature/library/controller/detail_quiz_controller.dart'; class DetailQuizView extends GetView { @@ -8,8 +10,6 @@ class DetailQuizView extends GetView { @override Widget build(BuildContext context) { - final data = controller.quizData.value; - return Scaffold( backgroundColor: AppColors.background, appBar: AppBar( @@ -34,7 +34,7 @@ class DetailQuizView extends GetView { children: [ // Header Section Text( - data.title, + controller.data.title, style: const TextStyle( fontSize: 22, fontWeight: FontWeight.bold, @@ -43,7 +43,7 @@ class DetailQuizView extends GetView { ), const SizedBox(height: 8), Text( - data.description, + controller.data.description ?? "", style: const TextStyle( fontSize: 14, color: AppColors.softGrayText, @@ -55,29 +55,35 @@ class DetailQuizView extends GetView { const Icon(Icons.calendar_today_rounded, size: 16, color: AppColors.softGrayText), const SizedBox(width: 6), Text( - _formatDate(data.date), + controller.data.date ?? "", style: const TextStyle(fontSize: 12, color: AppColors.softGrayText), ), const SizedBox(width: 12), const Icon(Icons.timer_rounded, size: 16, color: AppColors.softGrayText), const SizedBox(width: 6), Text( - '${data.limitDuration ~/ 60} menit', + '${controller.data.limitDuration ~/ 60} menit', style: const TextStyle(fontSize: 12, color: AppColors.softGrayText), ), ], ), const SizedBox(height: 20), - const Divider(thickness: 1.2, color: AppColors.borderLight), const SizedBox(height: 20), + GlobalButton(text: "Kerjakan", onPressed: () {}), + const SizedBox(height: 20), + GlobalButton(text: "buat ruangan", onPressed: () {}), + + const SizedBox(height: 20), + const Divider(thickness: 1.2, color: AppColors.borderLight), + const SizedBox(height: 20), // Soal Section ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - itemCount: data.questionListings.length, + itemCount: controller.data.questionListings.length, itemBuilder: (context, index) { - final question = data.questionListings[index]; + final question = controller.data.questionListings[index]; return _buildQuestionItem(question, index + 1); }, ), @@ -134,14 +140,14 @@ class DetailQuizView extends GetView { color: AppColors.darkText, ), ), - const SizedBox(height: 12), - Text( - 'Jawaban: ${question.targetAnswer}', - style: const TextStyle( - fontSize: 14, - color: AppColors.softGrayText, - ), - ), + // const SizedBox(height: 12), + // Text( + // 'Jawaban: ${question.targetAnswer}', + // style: const TextStyle( + // fontSize: 14, + // color: AppColors.softGrayText, + // ), + // ), const SizedBox(height: 8), Text( 'Durasi: ${question.duration} detik', @@ -167,8 +173,4 @@ class DetailQuizView extends GetView { return 'Tipe Tidak Diketahui'; } } - - String _formatDate(DateTime date) { - return '${date.day}/${date.month}/${date.year}'; - } } diff --git a/lib/feature/library/view/library_view.dart b/lib/feature/library/view/library_view.dart index 11c8da3..7f574c6 100644 --- a/lib/feature/library/view/library_view.dart +++ b/lib/feature/library/view/library_view.dart @@ -64,7 +64,7 @@ class LibraryView extends GetView { itemCount: controller.quizs.length, itemBuilder: (context, index) { final quiz = controller.quizs[index]; - return _buildQuizCard(quiz); + return InkWell(onTap: () => controller.goToDetail(index), child: _buildQuizCard(quiz)); }, ); }), diff --git a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart index 37d16b9..a357183 100644 --- a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart +++ b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart @@ -6,6 +6,7 @@ import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/core/utils/logger.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/models/quiz/question_create_request.dart'; +import 'package:quiz_app/data/models/quiz/question_listings_model.dart'; import 'package:quiz_app/data/models/quiz/quiestion_data_model.dart'; import 'package:quiz_app/data/services/quiz_service.dart'; From b80303b9c06d702ae38768ab0158e6376e0fc3ed Mon Sep 17 00:00:00 2001 From: akhdanre Date: Mon, 28 Apr 2025 19:30:09 +0700 Subject: [PATCH 028/104] feat: quiz play logic --- lib/app/routes/app_pages.dart | 7 + lib/app/routes/app_routes.dart | 2 + .../controller/detail_quiz_controller.dart | 3 + .../library/view/detail_quix_view.dart | 2 +- .../quiz_play/binding/quiz_play_binding.dart | 9 + .../controller/quiz_play_controller.dart | 164 ++++++++++++++++++ .../quiz_play/view/quiz_play_view.dart | 131 ++++++++++++++ 7 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 lib/feature/quiz_play/binding/quiz_play_binding.dart create mode 100644 lib/feature/quiz_play/controller/quiz_play_controller.dart create mode 100644 lib/feature/quiz_play/view/quiz_play_view.dart diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index 41296f7..c791205 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -13,6 +13,8 @@ import 'package:quiz_app/feature/navigation/views/navbar_view.dart'; import 'package:quiz_app/feature/profile/binding/profile_binding.dart'; import 'package:quiz_app/feature/quiz_creation/binding/quiz_creation_binding.dart'; import 'package:quiz_app/feature/quiz_creation/view/quiz_creation_view.dart'; +import 'package:quiz_app/feature/quiz_play/binding/quiz_play_binding.dart'; +import 'package:quiz_app/feature/quiz_play/view/quiz_play_view.dart'; import 'package:quiz_app/feature/quiz_preview/binding/quiz_preview_binding.dart'; import 'package:quiz_app/feature/quiz_preview/view/quiz_preview.dart'; import 'package:quiz_app/feature/register/binding/register_binding.dart'; @@ -71,6 +73,11 @@ class AppPages { name: AppRoutes.detailQuizPage, page: () => DetailQuizView(), binding: DetailQuizBinding(), + ), + GetPage( + name: AppRoutes.playQuizPage, + page: () => QuizPlayView(), + binding: QuizPlayBinding(), ) ]; } diff --git a/lib/app/routes/app_routes.dart b/lib/app/routes/app_routes.dart index 8f896b1..6f23a36 100644 --- a/lib/app/routes/app_routes.dart +++ b/lib/app/routes/app_routes.dart @@ -12,4 +12,6 @@ abstract class AppRoutes { static const quizPreviewPage = "/quiz/preview"; static const detailQuizPage = "/quiz/detail"; + + static const playQuizPage = "/quiz/play"; } diff --git a/lib/feature/library/controller/detail_quiz_controller.dart b/lib/feature/library/controller/detail_quiz_controller.dart index 34db30c..e5b04fe 100644 --- a/lib/feature/library/controller/detail_quiz_controller.dart +++ b/lib/feature/library/controller/detail_quiz_controller.dart @@ -1,4 +1,5 @@ import 'package:get/get.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/data/models/quiz/library_quiz_model.dart'; class DetailQuizController extends GetxController { @@ -12,4 +13,6 @@ class DetailQuizController extends GetxController { void loadData() { data = Get.arguments as QuizData; } + + void goToPlayPage() => Get.toNamed(AppRoutes.playQuizPage, arguments: data); } diff --git a/lib/feature/library/view/detail_quix_view.dart b/lib/feature/library/view/detail_quix_view.dart index 36055a5..5b0be23 100644 --- a/lib/feature/library/view/detail_quix_view.dart +++ b/lib/feature/library/view/detail_quix_view.dart @@ -70,7 +70,7 @@ class DetailQuizView extends GetView { const SizedBox(height: 20), const SizedBox(height: 20), - GlobalButton(text: "Kerjakan", onPressed: () {}), + GlobalButton(text: "Kerjakan", onPressed: controller.goToPlayPage), const SizedBox(height: 20), GlobalButton(text: "buat ruangan", onPressed: () {}), diff --git a/lib/feature/quiz_play/binding/quiz_play_binding.dart b/lib/feature/quiz_play/binding/quiz_play_binding.dart new file mode 100644 index 0000000..7b3017e --- /dev/null +++ b/lib/feature/quiz_play/binding/quiz_play_binding.dart @@ -0,0 +1,9 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/feature/quiz_play/controller/quiz_play_controller.dart'; + +class QuizPlayBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => QuizPlayController()); + } +} diff --git a/lib/feature/quiz_play/controller/quiz_play_controller.dart b/lib/feature/quiz_play/controller/quiz_play_controller.dart new file mode 100644 index 0000000..4736b29 --- /dev/null +++ b/lib/feature/quiz_play/controller/quiz_play_controller.dart @@ -0,0 +1,164 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/core/utils/logger.dart'; +import 'package:quiz_app/data/models/quiz/library_quiz_model.dart'; +import 'package:quiz_app/data/models/quiz/question_listings_model.dart'; + +class QuizPlayController extends GetxController { + late final QuizData quizData; + + final currentIndex = 0.obs; + final timeLeft = 0.obs; + final isStarting = true.obs; + final isAnswerSelected = false.obs; + final selectedAnswer = ''.obs; + final List answeredQuestions = []; + + final answerTextController = TextEditingController(); + final choosenAnswerTOF = 0.obs; + Timer? _timer; + + QuestionListing get currentQuestion => quizData.questionListings[currentIndex.value]; + + @override + void onInit() { + super.onInit(); + quizData = Get.arguments as QuizData; + _startCountdown(); + + // Listener untuk fill the blank + answerTextController.addListener(() { + if (answerTextController.text.trim().isNotEmpty) { + isAnswerSelected.value = true; + } else { + isAnswerSelected.value = false; + } + }); + + // Listener untuk pilihan true/false + ever(choosenAnswerTOF, (value) { + if (value != 0) { + isAnswerSelected.value = true; + } + }); + } + + void _startCountdown() async { + await Future.delayed(const Duration(seconds: 3)); + isStarting.value = false; + _startTimer(); + } + + void _startTimer() { + timeLeft.value = currentQuestion.duration; + _timer?.cancel(); + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (timeLeft.value > 0) { + timeLeft.value--; + } else { + _submitAnswerIfNeeded(); + _nextQuestion(); + } + }); + isAnswerSelected.value = false; + } + + void selectAnswer(String answer) { + selectedAnswer.value = answer; + isAnswerSelected.value = true; + } + + void onChooseTOF(bool value) { + choosenAnswerTOF.value = value ? 1 : 2; + selectedAnswer.value = value ? "Ya" : "Tidak"; + } + + void _submitAnswerIfNeeded() { + final question = currentQuestion; + String userAnswer = ""; + + if (question.type == "fill_the_blank") { + userAnswer = answerTextController.text.trim(); + } else if (question.type == "option") { + userAnswer = selectedAnswer.value.trim(); + } else if (question.type == "true_false") { + userAnswer = selectedAnswer.value.trim(); + } + + // Masukkan ke answeredQuestions + answeredQuestions.add(AnsweredQuestion( + index: currentIndex.value, + question: question.question, + selectedAnswer: userAnswer, + correctAnswer: question.targetAnswer.trim(), + isCorrect: userAnswer.toLowerCase() == question.targetAnswer.trim().toLowerCase(), + )); + } + + void nextQuestion() { + _submitAnswerIfNeeded(); + _nextQuestion(); + } + + void _nextQuestion() { + _timer?.cancel(); + if (currentIndex.value < quizData.questionListings.length - 1) { + currentIndex.value++; + answerTextController.clear(); + selectedAnswer.value = ''; + choosenAnswerTOF.value = 0; + _startTimer(); + } else { + _finishQuiz(); + } + } + + void _finishQuiz() { + _timer?.cancel(); + logC.i(answeredQuestions.map((e) => e.toJson()).toList()); + Get.defaultDialog( + title: "Selesai", + middleText: "Kamu telah menyelesaikan semua soal!", + onConfirm: () { + Get.back(); + Get.back(); + }, + textConfirm: "OK", + ); + } + + @override + void onClose() { + _timer?.cancel(); + answerTextController.dispose(); + super.onClose(); + } +} + +class AnsweredQuestion { + final int index; + final String question; + final String selectedAnswer; + final String correctAnswer; + final bool isCorrect; + + AnsweredQuestion({ + required this.index, + required this.question, + required this.selectedAnswer, + required this.correctAnswer, + required this.isCorrect, + }); + + Map toJson() { + return { + 'index': index, + 'question': question, + 'selectedAnswer': selectedAnswer, + 'correctAnswer': correctAnswer, + 'isCorrect': isCorrect, + }; + } +} diff --git a/lib/feature/quiz_play/view/quiz_play_view.dart b/lib/feature/quiz_play/view/quiz_play_view.dart new file mode 100644 index 0000000..0fe513d --- /dev/null +++ b/lib/feature/quiz_play/view/quiz_play_view.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/component/global_text_field.dart'; +import 'package:quiz_app/feature/quiz_play/controller/quiz_play_controller.dart'; + +class QuizPlayView extends GetView { + const QuizPlayView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF9FAFB), + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + automaticallyImplyLeading: false, + title: const Text( + 'Kerjakan Soal', + style: TextStyle(color: Colors.black, fontWeight: FontWeight.bold), + ), + iconTheme: const IconThemeData(color: Colors.black), + centerTitle: true, + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Obx(() { + final question = controller.currentQuestion; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LinearProgressIndicator( + value: controller.timeLeft.value / question.duration, + minHeight: 8, + backgroundColor: Colors.grey[300], + valueColor: const AlwaysStoppedAnimation(Color(0xFF2563EB)), + ), + const SizedBox(height: 20), + Text( + 'Soal ${controller.currentIndex.value + 1} dari ${controller.quizData.questionListings.length}', + style: const TextStyle( + fontSize: 16, + color: Colors.grey, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 12), + Text( + question.question, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ), + const SizedBox(height: 30), + + // Jawaban Berdasarkan Tipe Soal + if (question.type == 'option' && question.options != null) + ...List.generate(question.options!.length, (index) { + final option = question.options![index]; + return Container( + margin: const EdgeInsets.only(bottom: 12), + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + side: const BorderSide(color: Colors.grey), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + onPressed: () => controller.selectAnswer(option), + child: Text(option), + ), + ); + }) + else if (question.type == 'true_false') + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildTrueFalseButton('Ya', true, controller.choosenAnswerTOF), + _buildTrueFalseButton('Tidak', false, controller.choosenAnswerTOF), + ], + ) + else + GlobalTextField(controller: controller.answerTextController), + const Spacer(), + Obx(() { + return ElevatedButton( + onPressed: controller.nextQuestion, + style: ElevatedButton.styleFrom( + backgroundColor: controller.isAnswerSelected.value ? const Color(0xFF2563EB) : Colors.grey, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text( + 'Next', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ); + }), + ], + ); + }), + ), + ), + ); + } + + Widget _buildTrueFalseButton(String label, bool value, RxInt choosenAnswer) { + return Obx(() { + bool isSelected = (choosenAnswer.value == (value ? 1 : 2)); + return ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: isSelected ? (value ? Colors.green[100] : Colors.red[100]) : Colors.white, + foregroundColor: Colors.black, + side: const BorderSide(color: Colors.grey), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + onPressed: () => controller.onChooseTOF(value), + icon: Icon(value ? Icons.check_circle_outline : Icons.cancel_outlined), + label: Text(label), + ); + }); + } +} From 9797fd4a4fb63b37d64a821fe869f0618bdc1a1b Mon Sep 17 00:00:00 2001 From: akhdanre Date: Mon, 28 Apr 2025 20:32:16 +0700 Subject: [PATCH 029/104] feat: quiz result page --- lib/app/routes/app_pages.dart | 7 + lib/app/routes/app_routes.dart | 1 + .../controller/quiz_creation_controller.dart | 31 +++- .../controller/quiz_play_controller.dart | 37 +++-- .../quiz_play/view/quiz_play_view.dart | 7 +- .../controller/quiz_preview_controller.dart | 4 +- .../binding/quiz_result_binding.dart | 9 ++ .../controller/quiz_result_controller.dart | 43 ++++++ .../quiz_result/view/quiz_result_view.dart | 146 ++++++++++++++++++ 9 files changed, 260 insertions(+), 25 deletions(-) create mode 100644 lib/feature/quiz_result/binding/quiz_result_binding.dart create mode 100644 lib/feature/quiz_result/controller/quiz_result_controller.dart create mode 100644 lib/feature/quiz_result/view/quiz_result_view.dart diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index c791205..c9f278f 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -17,6 +17,8 @@ import 'package:quiz_app/feature/quiz_play/binding/quiz_play_binding.dart'; import 'package:quiz_app/feature/quiz_play/view/quiz_play_view.dart'; import 'package:quiz_app/feature/quiz_preview/binding/quiz_preview_binding.dart'; import 'package:quiz_app/feature/quiz_preview/view/quiz_preview.dart'; +import 'package:quiz_app/feature/quiz_result/binding/quiz_result_binding.dart'; +import 'package:quiz_app/feature/quiz_result/view/quiz_result_view.dart'; import 'package:quiz_app/feature/register/binding/register_binding.dart'; import 'package:quiz_app/feature/register/view/register_page.dart'; import 'package:quiz_app/feature/search/binding/search_binding.dart'; @@ -78,6 +80,11 @@ class AppPages { name: AppRoutes.playQuizPage, page: () => QuizPlayView(), binding: QuizPlayBinding(), + ), + GetPage( + name: AppRoutes.resultQuizPage, + page: () => QuizResultView(), + binding: QuizResultBinding(), ) ]; } diff --git a/lib/app/routes/app_routes.dart b/lib/app/routes/app_routes.dart index 6f23a36..8d4553a 100644 --- a/lib/app/routes/app_routes.dart +++ b/lib/app/routes/app_routes.dart @@ -14,4 +14,5 @@ abstract class AppRoutes { static const detailQuizPage = "/quiz/detail"; static const playQuizPage = "/quiz/play"; + static const resultQuizPage = "/quiz/result"; } diff --git a/lib/feature/quiz_creation/controller/quiz_creation_controller.dart b/lib/feature/quiz_creation/controller/quiz_creation_controller.dart index dc5462a..234ac02 100644 --- a/lib/feature/quiz_creation/controller/quiz_creation_controller.dart +++ b/lib/feature/quiz_creation/controller/quiz_creation_controller.dart @@ -90,8 +90,15 @@ class QuizCreationController extends GetxController { questionTC.text = data.question ?? ""; answerTC.text = data.answer ?? ""; - currentQuestionType.value = data.type ?? QuestionType.fillTheBlank; + currentQuestionType.value = data.type ?? QuestionType.fillTheBlank; + if (currentQuestionType.value == QuestionType.option) { + for (int i = 0; i < optionTCList.length; i++) { + optionTCList[i].text = data.options?[i].text ?? ""; + } + } else { + cleanInput(); + } if (data.options != null && data.options!.isNotEmpty) { for (int i = 0; i < optionTCList.length; i++) { optionTCList[i].text = data.options!.length > i ? data.options![i].text : ''; @@ -105,6 +112,12 @@ class QuizCreationController extends GetxController { } } + void cleanInput() { + for (final controller in optionTCList) { + controller.clear(); + } + } + void _updateCurrentQuestion({ String? question, String? answer, @@ -116,16 +129,18 @@ class QuizCreationController extends GetxController { quizData[selectedQuizIndex.value] = current.copyWith( question: question, answer: answer, - options: options ?? - (currentQuestionType.value == QuestionType.option - ? List.generate( - optionTCList.length, - (index) => OptionData(index: index, text: optionTCList[index].text), - ) - : null), + options: options, correctAnswerIndex: correctAnswerIndex, type: type, ); + + // ?? + // (currentQuestionType.value == QuestionType.option + // ? List.generate( + // optionTCList.length, + // (index) => OptionData(index: index, text: optionTCList[index].text), + // ) + // : null), } void updateTOFAnswer(bool answer) { diff --git a/lib/feature/quiz_play/controller/quiz_play_controller.dart b/lib/feature/quiz_play/controller/quiz_play_controller.dart index 4736b29..4e23248 100644 --- a/lib/feature/quiz_play/controller/quiz_play_controller.dart +++ b/lib/feature/quiz_play/controller/quiz_play_controller.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/core/utils/logger.dart'; import 'package:quiz_app/data/models/quiz/library_quiz_model.dart'; import 'package:quiz_app/data/models/quiz/question_listings_model.dart'; @@ -14,6 +15,7 @@ class QuizPlayController extends GetxController { final isStarting = true.obs; final isAnswerSelected = false.obs; final selectedAnswer = ''.obs; + final idxOptionSelected = (-1).obs; final List answeredQuestions = []; final answerTextController = TextEditingController(); @@ -65,14 +67,20 @@ class QuizPlayController extends GetxController { isAnswerSelected.value = false; } - void selectAnswer(String answer) { - selectedAnswer.value = answer; + void selectAnswerOption(int selectedIndex) { + selectedAnswer.value = selectedIndex.toString(); isAnswerSelected.value = true; + idxOptionSelected.value = selectedIndex; } + // void selectAnswerFTB(String answer) { + // selectedAnswer.value = answer; + // isAnswerSelected.value = true; + // } + void onChooseTOF(bool value) { choosenAnswerTOF.value = value ? 1 : 2; - selectedAnswer.value = value ? "Ya" : "Tidak"; + selectedAnswer.value = value.toString(); } void _submitAnswerIfNeeded() { @@ -87,7 +95,6 @@ class QuizPlayController extends GetxController { userAnswer = selectedAnswer.value.trim(); } - // Masukkan ke answeredQuestions answeredQuestions.add(AnsweredQuestion( index: currentIndex.value, question: question.question, @@ -117,15 +124,19 @@ class QuizPlayController extends GetxController { void _finishQuiz() { _timer?.cancel(); - logC.i(answeredQuestions.map((e) => e.toJson()).toList()); - Get.defaultDialog( - title: "Selesai", - middleText: "Kamu telah menyelesaikan semua soal!", - onConfirm: () { - Get.back(); - Get.back(); - }, - textConfirm: "OK", + // logC.i(answeredQuestions.map((e) => e.toJson()).toList()); + // Get.defaultDialog( + // title: "Selesai", + // middleText: "Kamu telah menyelesaikan semua soal!", + // onConfirm: () { + // Get.back(); + // Get.back(); + // }, + // textConfirm: "OK", + // ); + Get.toNamed( + AppRoutes.resultQuizPage, + arguments: [quizData.questionListings, answeredQuestions], ); } diff --git a/lib/feature/quiz_play/view/quiz_play_view.dart b/lib/feature/quiz_play/view/quiz_play_view.dart index 0fe513d..46981e6 100644 --- a/lib/feature/quiz_play/view/quiz_play_view.dart +++ b/lib/feature/quiz_play/view/quiz_play_view.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; import 'package:quiz_app/component/global_text_field.dart'; import 'package:quiz_app/feature/quiz_play/controller/quiz_play_controller.dart'; @@ -64,13 +65,13 @@ class QuizPlayView extends GetView { width: double.infinity, child: ElevatedButton( style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, + backgroundColor: controller.idxOptionSelected.value == index ? AppColors.primaryBlue : Colors.white, + foregroundColor: controller.idxOptionSelected.value == index ? Colors.white : Colors.black, side: const BorderSide(color: Colors.grey), padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), - onPressed: () => controller.selectAnswer(option), + onPressed: () => controller.selectAnswerOption(index), child: Text(option), ), ); diff --git a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart index a357183..d6b13d1 100644 --- a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart +++ b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart @@ -76,6 +76,7 @@ class QuizPreviewController extends GetxController { return questions.map((q) { String typeString; String answer = ""; + List? option; switch (q.type) { case QuestionType.fillTheBlank: typeString = 'fill_the_blank'; @@ -84,6 +85,7 @@ class QuizPreviewController extends GetxController { case QuestionType.option: typeString = 'option'; answer = q.correctAnswerIndex.toString(); + option = q.options?.map((o) => o.text).toList(); break; case QuestionType.trueOrFalse: typeString = 'true_false'; @@ -99,7 +101,7 @@ class QuizPreviewController extends GetxController { targetAnswer: answer, duration: 30, type: typeString, - options: q.options?.map((o) => o.text).toList(), + options: option, ); }).toList(); } diff --git a/lib/feature/quiz_result/binding/quiz_result_binding.dart b/lib/feature/quiz_result/binding/quiz_result_binding.dart new file mode 100644 index 0000000..6a1be4c --- /dev/null +++ b/lib/feature/quiz_result/binding/quiz_result_binding.dart @@ -0,0 +1,9 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/feature/quiz_result/controller/quiz_result_controller.dart'; + +class QuizResultBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => QuizResultController()); + } +} diff --git a/lib/feature/quiz_result/controller/quiz_result_controller.dart b/lib/feature/quiz_result/controller/quiz_result_controller.dart new file mode 100644 index 0000000..7bce6ca --- /dev/null +++ b/lib/feature/quiz_result/controller/quiz_result_controller.dart @@ -0,0 +1,43 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/data/models/quiz/question_listings_model.dart'; +import 'package:quiz_app/feature/quiz_play/controller/quiz_play_controller.dart'; + +class QuizResultController extends GetxController { + late final List questions; + late final List answers; + + RxInt correctAnswers = 0.obs; + RxInt totalQuestions = 0.obs; + RxDouble scorePercentage = 0.0.obs; + + @override + void onInit() { + super.onInit(); + loadData(); + calculateResult(); + } + + void loadData() { + final args = Get.arguments as List; + + questions = args[0] as List; + answers = args[1] as List; + totalQuestions.value = questions.length; + } + + void calculateResult() { + int correct = answers.where((a) => a.isCorrect).length; + correctAnswers.value = correct; + if (totalQuestions.value > 0) { + scorePercentage.value = (correctAnswers.value / totalQuestions.value) * 100; + } + } + + String getResultMessage() { + if (scorePercentage.value >= 80) { + return "Lulus 🎉"; + } else { + return "Belum Lulus 😔"; + } + } +} diff --git a/lib/feature/quiz_result/view/quiz_result_view.dart b/lib/feature/quiz_result/view/quiz_result_view.dart new file mode 100644 index 0000000..9e226cb --- /dev/null +++ b/lib/feature/quiz_result/view/quiz_result_view.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/feature/quiz_result/controller/quiz_result_controller.dart'; + +class QuizResultView extends GetView { + const QuizResultView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF9FAFB), + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + automaticallyImplyLeading: false, + title: const Text( + 'Hasil Kuis', + style: TextStyle(color: Colors.black, fontWeight: FontWeight.bold), + ), + iconTheme: const IconThemeData(color: Colors.black), + centerTitle: true, + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Obx(() => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildScoreSummary(), + const SizedBox(height: 16), + Expanded( + child: ListView.builder( + itemCount: controller.questions.length, + itemBuilder: (context, index) { + return _buildQuestionResult(index); + }, + ), + ), + ], + )), + ), + ), + ); + } + + Widget _buildScoreSummary() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Skor Kamu: ${controller.correctAnswers}/${controller.totalQuestions}", + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + LinearProgressIndicator( + value: controller.scorePercentage.value / 100, + minHeight: 10, + backgroundColor: AppColors.borderLight, + valueColor: const AlwaysStoppedAnimation(AppColors.primaryBlue), + ), + const SizedBox(height: 8), + Text( + controller.getResultMessage(), + style: TextStyle( + fontSize: 16, + color: controller.scorePercentage.value >= 80 ? Colors.green : Colors.red, + ), + ), + ], + ); + } + + Widget _buildQuestionResult(int index) { + final answered = controller.answers[index]; + final isCorrect = answered.isCorrect; + + return Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.borderLight), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 6, + offset: const Offset(2, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Soal ${answered.index + 1}', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: AppColors.darkText)), + const SizedBox(height: 6), + Text( + // Tidak ada tipe soal di AnsweredQuestion, jadi kalau mau kasih tipe harus pakai question[index].type + // kalau tidak mau ribet, ini bisa dihapus saja + // controller.mapQuestionTypeToText(controller.questions[answered.index].type), + '', // kosongkan dulu + style: const TextStyle(fontSize: 12, color: AppColors.softGrayText, fontStyle: FontStyle.italic), + ), + const SizedBox(height: 12), + Text( + answered.question, + style: const TextStyle(fontSize: 16, color: AppColors.darkText), + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.person, size: 18, color: AppColors.primaryBlue), + const SizedBox(width: 6), + Expanded( + child: Text( + "Jawaban Kamu: ${answered.selectedAnswer}", + style: TextStyle( + color: isCorrect ? Colors.green : Colors.red, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.check, size: 18, color: AppColors.softGrayText), + const SizedBox(width: 6), + Expanded( + child: Text( + "Jawaban Benar: ${answered.correctAnswer}", + style: const TextStyle(color: AppColors.darkText), + ), + ), + ], + ), + ], + ), + ); + } +} From 51182b8c7bfc741355b6d687575f8a621998d6c1 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Mon, 28 Apr 2025 20:58:45 +0700 Subject: [PATCH 030/104] feat: done on quiz result --- .../widget/question_container_widget.dart | 194 ++++++++++++++++++ .../controller/quiz_play_controller.dart | 3 +- .../controller/quiz_preview_controller.dart | 148 ------------- .../quiz_preview/view/quiz_preview.dart | 3 +- .../controller/quiz_result_controller.dart | 52 ++++- .../quiz_result/view/quiz_result_view.dart | 147 ++++++------- 6 files changed, 323 insertions(+), 224 deletions(-) create mode 100644 lib/component/widget/question_container_widget.dart diff --git a/lib/component/widget/question_container_widget.dart b/lib/component/widget/question_container_widget.dart new file mode 100644 index 0000000..9cd75e4 --- /dev/null +++ b/lib/component/widget/question_container_widget.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/app/const/enums/question_type.dart'; +import 'package:quiz_app/data/models/quiz/quiestion_data_model.dart'; +import 'package:quiz_app/feature/quiz_play/controller/quiz_play_controller.dart'; + +class QuestionContainerWidget extends StatelessWidget { + final QuestionData question; + final AnsweredQuestion? answeredQuestion; + + const QuestionContainerWidget({super.key, required this.question, this.answeredQuestion}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 20), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.borderLight), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 6, + offset: const Offset(2, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Soal ${question.index}', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: AppColors.darkText)), + const SizedBox(height: 6), + Text( + _mapQuestionTypeToText(question.type), + style: const TextStyle(fontSize: 12, color: AppColors.softGrayText, fontStyle: FontStyle.italic), + ), + const SizedBox(height: 12), + Text( + question.question ?? '-', + style: const TextStyle(fontSize: 16, color: AppColors.darkText), + ), + const SizedBox(height: 16), + _buildAnswerSection(question), + if (answeredQuestion != null) ...[ + const SizedBox(height: 16), + _buildAnsweredSection(answeredQuestion!), + ], + const SizedBox(height: 10), + const Text( + 'Durasi: 0 detik', + style: TextStyle(fontSize: 14, color: AppColors.softGrayText), + ), + ], + ), + ); + } + + Widget _buildAnswerSection(QuestionData question) { + if (question.type == QuestionType.option && question.options != null) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: question.options!.map((option) { + bool isCorrect = question.correctAnswerIndex == option.index; + return Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: isCorrect ? AppColors.primaryBlue.withOpacity(0.1) : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + isCorrect ? Icons.check_circle_rounded : Icons.circle_outlined, + size: 18, + color: isCorrect ? AppColors.primaryBlue : AppColors.softGrayText, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + option.text, + style: TextStyle( + fontWeight: isCorrect ? FontWeight.bold : FontWeight.normal, + color: isCorrect ? AppColors.primaryBlue : AppColors.darkText, + ), + ), + ), + ], + ), + ); + }).toList(), + ); + } else if (question.type == QuestionType.fillTheBlank) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: _buildFillTheBlankPossibilities(question.answer ?? '-'), + ); + } else if (question.type == QuestionType.trueOrFalse) { + return Text( + 'Jawaban: ${question.answer ?? '-'}', + style: const TextStyle(color: AppColors.softGrayText), + ); + } else { + return const SizedBox(); + } + } + + Widget _buildAnsweredSection(AnsweredQuestion answered) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Jawaban Anda:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: AppColors.darkText)), + const SizedBox(height: 6), + Row( + children: [ + Icon( + answered.isCorrect ? Icons.check_circle : Icons.cancel, + color: answered.isCorrect ? Colors.green : Colors.red, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + answered.selectedAnswer, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: answered.isCorrect ? Colors.green : Colors.red, + ), + ), + ), + ], + ), + ], + ); + } + + String _mapQuestionTypeToText(QuestionType? type) { + switch (type) { + case QuestionType.option: + return 'Tipe: Pilihan Ganda'; + case QuestionType.fillTheBlank: + return 'Tipe: Isian Kosong'; + case QuestionType.trueOrFalse: + return 'Tipe: Benar / Salah'; + default: + return 'Tipe: Tidak diketahui'; + } + } + + List _buildFillTheBlankPossibilities(String answer) { + List possibilities = [ + _capitalizeEachWord(answer), + answer.toLowerCase(), + _capitalizeFirstWordOnly(answer), + ]; + + return possibilities.map((option) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + const Icon(Icons.arrow_right, size: 18, color: AppColors.softGrayText), + const SizedBox(width: 6), + Text( + option, + style: const TextStyle(color: AppColors.darkText), + ), + ], + ), + ); + }).toList(); + } + + String _capitalizeEachWord(String text) { + return text.split(' ').map((word) { + if (word.isEmpty) return word; + return word[0].toUpperCase() + word.substring(1).toLowerCase(); + }).join(' '); + } + + String _capitalizeFirstWordOnly(String text) { + if (text.isEmpty) return text; + List parts = text.split(' '); + parts[0] = parts[0][0].toUpperCase() + parts[0].substring(1).toLowerCase(); + for (int i = 1; i < parts.length; i++) { + parts[i] = parts[i].toLowerCase(); + } + return parts.join(' '); + } +} diff --git a/lib/feature/quiz_play/controller/quiz_play_controller.dart b/lib/feature/quiz_play/controller/quiz_play_controller.dart index 4e23248..ce1a4cd 100644 --- a/lib/feature/quiz_play/controller/quiz_play_controller.dart +++ b/lib/feature/quiz_play/controller/quiz_play_controller.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; -import 'package:quiz_app/core/utils/logger.dart'; import 'package:quiz_app/data/models/quiz/library_quiz_model.dart'; import 'package:quiz_app/data/models/quiz/question_listings_model.dart'; @@ -136,7 +135,7 @@ class QuizPlayController extends GetxController { // ); Get.toNamed( AppRoutes.resultQuizPage, - arguments: [quizData.questionListings, answeredQuestions], + arguments: [quizData, answeredQuestions], ); } diff --git a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart index d6b13d1..0bfa96c 100644 --- a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart +++ b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:quiz_app/app/const/colors/app_colors.dart'; import 'package:quiz_app/app/const/enums/question_type.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/core/utils/logger.dart'; @@ -106,153 +105,6 @@ class QuizPreviewController extends GetxController { }).toList(); } - Widget buildQuestionCard(QuestionData question) { - return Container( - width: double.infinity, - margin: const EdgeInsets.only(bottom: 20), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.borderLight), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 6, - offset: const Offset(2, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Soal ${question.index}', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: AppColors.darkText)), - const SizedBox(height: 6), - Text( - _mapQuestionTypeToText(question.type), - style: const TextStyle(fontSize: 12, color: AppColors.softGrayText, fontStyle: FontStyle.italic), - ), - const SizedBox(height: 12), - Text( - question.question ?? '-', - style: const TextStyle(fontSize: 16, color: AppColors.darkText), - ), - const SizedBox(height: 16), - _buildAnswerSection(question), - const SizedBox(height: 10), - const Text( - 'Durasi: 0 detik', - style: TextStyle(fontSize: 14, color: AppColors.softGrayText), - ), - ], - ), - ); - } - - Widget _buildAnswerSection(QuestionData question) { - if (question.type == QuestionType.option && question.options != null) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: question.options!.map((option) { - bool isCorrect = question.correctAnswerIndex == option.index; - return Container( - margin: const EdgeInsets.symmetric(vertical: 4), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - decoration: BoxDecoration( - color: isCorrect ? AppColors.primaryBlue.withOpacity(0.1) : Colors.transparent, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Icon( - isCorrect ? Icons.check_circle_rounded : Icons.circle_outlined, - size: 18, - color: isCorrect ? AppColors.primaryBlue : AppColors.softGrayText, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - option.text, - style: TextStyle( - fontWeight: isCorrect ? FontWeight.bold : FontWeight.normal, - color: isCorrect ? AppColors.primaryBlue : AppColors.darkText, - ), - ), - ), - ], - ), - ); - }).toList(), - ); - } else if (question.type == QuestionType.fillTheBlank) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: _buildFillTheBlankPossibilities(question.answer ?? '-'), - ); - } else if (question.type == QuestionType.trueOrFalse) { - return Text( - 'Jawaban: ${question.answer ?? '-'}', - style: const TextStyle(color: AppColors.softGrayText), - ); - } else { - return const SizedBox(); - } - } - - String _mapQuestionTypeToText(QuestionType? type) { - switch (type) { - case QuestionType.option: - return 'Tipe: Pilihan Ganda'; - case QuestionType.fillTheBlank: - return 'Tipe: Isian Kosong'; - case QuestionType.trueOrFalse: - return 'Tipe: Benar / Salah'; - default: - return 'Tipe: Tidak diketahui'; - } - } - - List _buildFillTheBlankPossibilities(String answer) { - List possibilities = [ - _capitalizeEachWord(answer), - answer.toLowerCase(), - _capitalizeFirstWordOnly(answer), - ]; - - return possibilities.map((option) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Row( - children: [ - const Icon(Icons.arrow_right, size: 18, color: AppColors.softGrayText), - const SizedBox(width: 6), - Text( - option, - style: const TextStyle(color: AppColors.darkText), - ), - ], - ), - ); - }).toList(); - } - - String _capitalizeEachWord(String text) { - return text.split(' ').map((word) { - if (word.isEmpty) return word; - return word[0].toUpperCase() + word.substring(1).toLowerCase(); - }).join(' '); - } - - String _capitalizeFirstWordOnly(String text) { - if (text.isEmpty) return text; - List parts = text.split(' '); - parts[0] = parts[0][0].toUpperCase() + parts[0].substring(1).toLowerCase(); - for (int i = 1; i < parts.length; i++) { - parts[i] = parts[i].toLowerCase(); - } - return parts.join(' '); - } - @override void onClose() { titleController.dispose(); diff --git a/lib/feature/quiz_preview/view/quiz_preview.dart b/lib/feature/quiz_preview/view/quiz_preview.dart index 48b721c..b50401c 100644 --- a/lib/feature/quiz_preview/view/quiz_preview.dart +++ b/lib/feature/quiz_preview/view/quiz_preview.dart @@ -4,6 +4,7 @@ import 'package:quiz_app/app/const/colors/app_colors.dart'; import 'package:quiz_app/component/global_button.dart'; import 'package:quiz_app/component/global_text_field.dart'; import 'package:quiz_app/component/label_text_field.dart'; +import 'package:quiz_app/component/widget/question_container_widget.dart'; import 'package:quiz_app/feature/quiz_preview/controller/quiz_preview_controller.dart'; class QuizPreviewPage extends GetView { @@ -61,7 +62,7 @@ class QuizPreviewPage extends GetView { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: controller.data.map((question) { - return controller.buildQuestionCard(question); + return QuestionContainerWidget(question: question); }).toList(), ); } diff --git a/lib/feature/quiz_result/controller/quiz_result_controller.dart b/lib/feature/quiz_result/controller/quiz_result_controller.dart index 7bce6ca..c6c7884 100644 --- a/lib/feature/quiz_result/controller/quiz_result_controller.dart +++ b/lib/feature/quiz_result/controller/quiz_result_controller.dart @@ -1,8 +1,12 @@ import 'package:get/get.dart'; +import 'package:quiz_app/app/const/enums/question_type.dart'; +import 'package:quiz_app/data/models/quiz/library_quiz_model.dart'; import 'package:quiz_app/data/models/quiz/question_listings_model.dart'; +import 'package:quiz_app/data/models/quiz/quiestion_data_model.dart'; import 'package:quiz_app/feature/quiz_play/controller/quiz_play_controller.dart'; class QuizResultController extends GetxController { + late final QuizData question; late final List questions; late final List answers; @@ -20,7 +24,8 @@ class QuizResultController extends GetxController { void loadData() { final args = Get.arguments as List; - questions = args[0] as List; + question = args[0] as QuizData; + questions = question.questionListings; answers = args[1] as List; totalQuestions.value = questions.length; } @@ -40,4 +45,49 @@ class QuizResultController extends GetxController { return "Belum Lulus 😔"; } } + + QuestionData mapQuestionListingToQuestionData(QuestionListing questionListing, int index) { + // Convert type string ke enum + QuestionType? questionType; + switch (questionListing.type) { + case 'fill_the_blank': + questionType = QuestionType.fillTheBlank; + break; + case 'option': + questionType = QuestionType.option; + break; + case 'true_false': + questionType = QuestionType.trueOrFalse; + break; + default: + questionType = null; + } + + // Convert options ke OptionData + List? optionDataList; + if (questionListing.options != null) { + optionDataList = []; + for (int i = 0; i < questionListing.options!.length; i++) { + optionDataList.add(OptionData(index: i, text: questionListing.options![i])); + } + } + + // Cari correctAnswerIndex kalau tipe-nya option + int? correctAnswerIndex; + if (questionType == QuestionType.option && optionDataList != null) { + correctAnswerIndex = optionDataList.indexWhere((option) => option.text == questionListing.targetAnswer); + if (correctAnswerIndex == -1) { + correctAnswerIndex = null; // Kalau tidak ketemu + } + } + + return QuestionData( + index: index, + question: questionListing.question, + answer: questionListing.targetAnswer, + options: optionDataList, + correctAnswerIndex: correctAnswerIndex, + type: questionType, + ); + } } diff --git a/lib/feature/quiz_result/view/quiz_result_view.dart b/lib/feature/quiz_result/view/quiz_result_view.dart index 9e226cb..059b742 100644 --- a/lib/feature/quiz_result/view/quiz_result_view.dart +++ b/lib/feature/quiz_result/view/quiz_result_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/component/widget/question_container_widget.dart'; import 'package:quiz_app/feature/quiz_result/controller/quiz_result_controller.dart'; class QuizResultView extends GetView { @@ -33,7 +34,9 @@ class QuizResultView extends GetView { child: ListView.builder( itemCount: controller.questions.length, itemBuilder: (context, index) { - return _buildQuestionResult(index); + return QuestionContainerWidget( + question: controller.mapQuestionListingToQuestionData(controller.questions[index], index), + answeredQuestion: controller.answers[index]); }, ), ), @@ -71,76 +74,76 @@ class QuizResultView extends GetView { ); } - Widget _buildQuestionResult(int index) { - final answered = controller.answers[index]; - final isCorrect = answered.isCorrect; + // Widget _buildQuestionResult(int index) { + // final answered = controller.answers[index]; + // final isCorrect = answered.isCorrect; - return Container( - width: double.infinity, - margin: const EdgeInsets.only(bottom: 16), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.borderLight), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 6, - offset: const Offset(2, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Soal ${answered.index + 1}', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: AppColors.darkText)), - const SizedBox(height: 6), - Text( - // Tidak ada tipe soal di AnsweredQuestion, jadi kalau mau kasih tipe harus pakai question[index].type - // kalau tidak mau ribet, ini bisa dihapus saja - // controller.mapQuestionTypeToText(controller.questions[answered.index].type), - '', // kosongkan dulu - style: const TextStyle(fontSize: 12, color: AppColors.softGrayText, fontStyle: FontStyle.italic), - ), - const SizedBox(height: 12), - Text( - answered.question, - style: const TextStyle(fontSize: 16, color: AppColors.darkText), - ), - const SizedBox(height: 12), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon(Icons.person, size: 18, color: AppColors.primaryBlue), - const SizedBox(width: 6), - Expanded( - child: Text( - "Jawaban Kamu: ${answered.selectedAnswer}", - style: TextStyle( - color: isCorrect ? Colors.green : Colors.red, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - const SizedBox(height: 4), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon(Icons.check, size: 18, color: AppColors.softGrayText), - const SizedBox(width: 6), - Expanded( - child: Text( - "Jawaban Benar: ${answered.correctAnswer}", - style: const TextStyle(color: AppColors.darkText), - ), - ), - ], - ), - ], - ), - ); - } + // return Container( + // width: double.infinity, + // margin: const EdgeInsets.only(bottom: 16), + // padding: const EdgeInsets.all(16), + // decoration: BoxDecoration( + // color: Colors.white, + // borderRadius: BorderRadius.circular(12), + // border: Border.all(color: AppColors.borderLight), + // boxShadow: [ + // BoxShadow( + // color: Colors.black.withOpacity(0.05), + // blurRadius: 6, + // offset: const Offset(2, 2), + // ), + // ], + // ), + // child: Column( + // crossAxisAlignment: CrossAxisAlignment.start, + // children: [ + // Text('Soal ${answered.index + 1}', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: AppColors.darkText)), + // const SizedBox(height: 6), + // Text( + // // Tidak ada tipe soal di AnsweredQuestion, jadi kalau mau kasih tipe harus pakai question[index].type + // // kalau tidak mau ribet, ini bisa dihapus saja + // // controller.mapQuestionTypeToText(controller.questions[answered.index].type), + // '', // kosongkan dulu + // style: const TextStyle(fontSize: 12, color: AppColors.softGrayText, fontStyle: FontStyle.italic), + // ), + // const SizedBox(height: 12), + // Text( + // answered.question, + // style: const TextStyle(fontSize: 16, color: AppColors.darkText), + // ), + // const SizedBox(height: 12), + // Row( + // crossAxisAlignment: CrossAxisAlignment.start, + // children: [ + // const Icon(Icons.person, size: 18, color: AppColors.primaryBlue), + // const SizedBox(width: 6), + // Expanded( + // child: Text( + // "Jawaban Kamu: ${answered.selectedAnswer}", + // style: TextStyle( + // color: isCorrect ? Colors.green : Colors.red, + // fontWeight: FontWeight.bold, + // ), + // ), + // ), + // ], + // ), + // const SizedBox(height: 4), + // Row( + // crossAxisAlignment: CrossAxisAlignment.start, + // children: [ + // const Icon(Icons.check, size: 18, color: AppColors.softGrayText), + // const SizedBox(width: 6), + // Expanded( + // child: Text( + // "Jawaban Benar: ${answered.correctAnswer}", + // style: const TextStyle(color: AppColors.darkText), + // ), + // ), + // ], + // ), + // ], + // ), + // ); + // } } From 92f349e8badf2d63147a9b539fce0b921a5e355f Mon Sep 17 00:00:00 2001 From: akhdanre Date: Tue, 29 Apr 2025 23:00:48 +0700 Subject: [PATCH 031/104] feat: add index and input validation --- .../widget/question_container_widget.dart | 4 +- .../models/quiz/question_listings_model.dart | 4 + .../models/quiz/quiestion_data_model.dart | 12 +- .../controller/quiz_creation_controller.dart | 61 +++++-- .../component/custom_question_component.dart | 28 ++-- .../controller/quiz_play_controller.dart | 13 +- .../controller/quiz_preview_controller.dart | 9 +- .../controller/quiz_result_controller.dart | 11 +- .../quiz_result/view/quiz_result_view.dart | 149 ++++++------------ 9 files changed, 130 insertions(+), 161 deletions(-) diff --git a/lib/component/widget/question_container_widget.dart b/lib/component/widget/question_container_widget.dart index 9cd75e4..18b71d4 100644 --- a/lib/component/widget/question_container_widget.dart +++ b/lib/component/widget/question_container_widget.dart @@ -49,8 +49,8 @@ class QuestionContainerWidget extends StatelessWidget { _buildAnsweredSection(answeredQuestion!), ], const SizedBox(height: 10), - const Text( - 'Durasi: 0 detik', + Text( + 'Durasi: ${question.duration} detik', style: TextStyle(fontSize: 14, color: AppColors.softGrayText), ), ], diff --git a/lib/data/models/quiz/question_listings_model.dart b/lib/data/models/quiz/question_listings_model.dart index 0c5a331..2e74dde 100644 --- a/lib/data/models/quiz/question_listings_model.dart +++ b/lib/data/models/quiz/question_listings_model.dart @@ -1,4 +1,5 @@ class QuestionListing { + final int index; final String question; final String targetAnswer; final int duration; @@ -6,6 +7,7 @@ class QuestionListing { final List? options; QuestionListing({ + required this.index, required this.question, required this.targetAnswer, required this.duration, @@ -15,6 +17,7 @@ class QuestionListing { factory QuestionListing.fromJson(Map json) { return QuestionListing( + index: json['index'], question: json['question'], targetAnswer: json['target_answer'], duration: json['duration'], @@ -25,6 +28,7 @@ class QuestionListing { Map toJson() { return { + 'index': index, 'question': question, 'target_answer': targetAnswer, 'duration': duration, diff --git a/lib/data/models/quiz/quiestion_data_model.dart b/lib/data/models/quiz/quiestion_data_model.dart index 10a7880..55d06a7 100644 --- a/lib/data/models/quiz/quiestion_data_model.dart +++ b/lib/data/models/quiz/quiestion_data_model.dart @@ -14,6 +14,7 @@ class QuestionData { final List? options; final int? correctAnswerIndex; final QuestionType? type; + final int duration; QuestionData({ required this.index, @@ -21,17 +22,11 @@ class QuestionData { this.answer, this.options, this.correctAnswerIndex, + this.duration = 30, this.type, }); - QuestionData copyWith({ - int? index, - String? question, - String? answer, - List? options, - int? correctAnswerIndex, - QuestionType? type, - }) { + QuestionData copyWith({int? index, String? question, String? answer, List? options, int? correctAnswerIndex, QuestionType? type, int? duration}) { return QuestionData( index: index ?? this.index, question: question ?? this.question, @@ -39,6 +34,7 @@ class QuestionData { options: options ?? this.options, correctAnswerIndex: correctAnswerIndex ?? this.correctAnswerIndex, type: type ?? this.type, + duration: duration ?? this.duration, ); } } diff --git a/lib/feature/quiz_creation/controller/quiz_creation_controller.dart b/lib/feature/quiz_creation/controller/quiz_creation_controller.dart index 234ac02..5d72a76 100644 --- a/lib/feature/quiz_creation/controller/quiz_creation_controller.dart +++ b/lib/feature/quiz_creation/controller/quiz_creation_controller.dart @@ -18,6 +18,8 @@ class QuizCreationController extends GetxController { RxList quizData = [QuestionData(index: 1, type: QuestionType.fillTheBlank)].obs; RxInt selectedQuizIndex = 0.obs; + RxInt currentDuration = 30.obs; + @override void onInit() { super.onInit(); @@ -91,6 +93,8 @@ class QuizCreationController extends GetxController { questionTC.text = data.question ?? ""; answerTC.text = data.answer ?? ""; + currentDuration.value = data.duration; + currentQuestionType.value = data.type ?? QuestionType.fillTheBlank; if (currentQuestionType.value == QuestionType.option) { for (int i = 0; i < optionTCList.length; i++) { @@ -118,13 +122,7 @@ class QuizCreationController extends GetxController { } } - void _updateCurrentQuestion({ - String? question, - String? answer, - List? options, - int? correctAnswerIndex, - QuestionType? type, - }) { + void _updateCurrentQuestion({String? question, String? answer, List? options, int? correctAnswerIndex, QuestionType? type, int? duration}) { final current = quizData[selectedQuizIndex.value]; quizData[selectedQuizIndex.value] = current.copyWith( question: question, @@ -132,22 +130,55 @@ class QuizCreationController extends GetxController { options: options, correctAnswerIndex: correctAnswerIndex, type: type, + duration: duration, ); - - // ?? - // (currentQuestionType.value == QuestionType.option - // ? List.generate( - // optionTCList.length, - // (index) => OptionData(index: index, text: optionTCList[index].text), - // ) - // : null), } void updateTOFAnswer(bool answer) { _updateCurrentQuestion(answer: answer.toString()); } + void onDurationChange(int? duration) { + currentDuration.value = duration ?? 30; + _updateCurrentQuestion(duration: duration); + } + void onDone() { + for (final value in quizData) { + if (value.question == null) { + Get.snackbar( + 'Field kosong di soal ${value.index}', + 'Hapus jika tidak digunakan', + ); + return; + } + + if (value.type == QuestionType.option) { + if (value.correctAnswerIndex == null) { + Get.snackbar( + 'Field kosong di soal ${value.index}', + 'Hapus jika tidak digunakan', + ); + return; + } + if (value.options == null || value.options!.length < 4) { + Get.snackbar( + 'Pilihan jawaban kurang dari 4 di soal ${value.index}', + 'Tambahkan pilihan jawaban', + ); + return; + } + } else { + if (value.answer == null) { + Get.snackbar( + 'Field kosong di soal ${value.index}', + 'Hapus jika tidak digunakan', + ); + return; + } + } + } + Get.toNamed(AppRoutes.quizPreviewPage, arguments: quizData); } diff --git a/lib/feature/quiz_creation/view/component/custom_question_component.dart b/lib/feature/quiz_creation/view/component/custom_question_component.dart index 18b0ee2..c543240 100644 --- a/lib/feature/quiz_creation/view/component/custom_question_component.dart +++ b/lib/feature/quiz_creation/view/component/custom_question_component.dart @@ -180,20 +180,20 @@ class CustomQuestionComponent extends GetView { borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.borderLight), ), - child: DropdownButtonFormField( - value: '1 minute', - decoration: const InputDecoration( - border: InputBorder.none, - contentPadding: EdgeInsets.symmetric(vertical: 14), - ), - style: const TextStyle(color: AppColors.darkText, fontWeight: FontWeight.w500), - items: const [ - DropdownMenuItem(value: '30 seconds', child: Text('30 seconds')), - DropdownMenuItem(value: '1 minute', child: Text('1 minute')), - DropdownMenuItem(value: '2 minutes', child: Text('2 minutes')), - ], - onChanged: (value) {}, - ), + child: Obx(() => DropdownButtonFormField( + value: controller.currentDuration.value, + decoration: const InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric(vertical: 14), + ), + style: const TextStyle(color: AppColors.darkText, fontWeight: FontWeight.w500), + items: const [ + DropdownMenuItem(value: 10, child: Text('10 detik')), + DropdownMenuItem(value: 20, child: Text('20 detik')), + DropdownMenuItem(value: 30, child: Text('30 detik')), + DropdownMenuItem(value: 60, child: Text('1 menit')), + ], + onChanged: controller.onDurationChange)), ); } } diff --git a/lib/feature/quiz_play/controller/quiz_play_controller.dart b/lib/feature/quiz_play/controller/quiz_play_controller.dart index ce1a4cd..355fa0a 100644 --- a/lib/feature/quiz_play/controller/quiz_play_controller.dart +++ b/lib/feature/quiz_play/controller/quiz_play_controller.dart @@ -123,17 +123,8 @@ class QuizPlayController extends GetxController { void _finishQuiz() { _timer?.cancel(); - // logC.i(answeredQuestions.map((e) => e.toJson()).toList()); - // Get.defaultDialog( - // title: "Selesai", - // middleText: "Kamu telah menyelesaikan semua soal!", - // onConfirm: () { - // Get.back(); - // Get.back(); - // }, - // textConfirm: "OK", - // ); - Get.toNamed( + + Get.offAllNamed( AppRoutes.resultQuizPage, arguments: [quizData, answeredQuestions], ); diff --git a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart index 0bfa96c..03d87d4 100644 --- a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart +++ b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart @@ -72,10 +72,14 @@ class QuizPreviewController extends GetxController { } List _mapQuestionsToListings(List questions) { - return questions.map((q) { + return questions.asMap().entries.map((entry) { + final index = entry.key; + final q = entry.value; + String typeString; String answer = ""; List? option; + switch (q.type) { case QuestionType.fillTheBlank: typeString = 'fill_the_blank'; @@ -96,9 +100,10 @@ class QuizPreviewController extends GetxController { } return QuestionListing( + index: index, question: q.question ?? '', targetAnswer: answer, - duration: 30, + duration: q.duration, type: typeString, options: option, ); diff --git a/lib/feature/quiz_result/controller/quiz_result_controller.dart b/lib/feature/quiz_result/controller/quiz_result_controller.dart index c6c7884..ee36b26 100644 --- a/lib/feature/quiz_result/controller/quiz_result_controller.dart +++ b/lib/feature/quiz_result/controller/quiz_result_controller.dart @@ -1,5 +1,6 @@ import 'package:get/get.dart'; import 'package:quiz_app/app/const/enums/question_type.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/data/models/quiz/library_quiz_model.dart'; import 'package:quiz_app/data/models/quiz/question_listings_model.dart'; import 'package:quiz_app/data/models/quiz/quiestion_data_model.dart'; @@ -39,11 +40,7 @@ class QuizResultController extends GetxController { } String getResultMessage() { - if (scorePercentage.value >= 80) { - return "Lulus 🎉"; - } else { - return "Belum Lulus 😔"; - } + return "Nilai kamu ${scorePercentage.value}"; } QuestionData mapQuestionListingToQuestionData(QuestionListing questionListing, int index) { @@ -90,4 +87,8 @@ class QuizResultController extends GetxController { type: questionType, ); } + + void onPopInvoke(bool isPop, dynamic value) { + Get.offNamed(AppRoutes.mainPage, arguments: 3); + } } diff --git a/lib/feature/quiz_result/view/quiz_result_view.dart b/lib/feature/quiz_result/view/quiz_result_view.dart index 059b742..e15f974 100644 --- a/lib/feature/quiz_result/view/quiz_result_view.dart +++ b/lib/feature/quiz_result/view/quiz_result_view.dart @@ -9,44 +9,58 @@ class QuizResultView extends GetView { @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: const Color(0xFFF9FAFB), - appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - automaticallyImplyLeading: false, - title: const Text( - 'Hasil Kuis', - style: TextStyle(color: Colors.black, fontWeight: FontWeight.bold), - ), - iconTheme: const IconThemeData(color: Colors.black), - centerTitle: true, - ), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(16), - child: Obx(() => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildScoreSummary(), - const SizedBox(height: 16), - Expanded( - child: ListView.builder( - itemCount: controller.questions.length, - itemBuilder: (context, index) { - return QuestionContainerWidget( + return PopScope( + canPop: false, + onPopInvokedWithResult: controller.onPopInvoke, + child: Scaffold( + backgroundColor: const Color(0xFFF9FAFB), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Obx(() => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCustomAppBar(context), + const SizedBox(height: 16), + _buildScoreSummary(), + const SizedBox(height: 16), + Expanded( + child: ListView.builder( + itemCount: controller.questions.length, + itemBuilder: (context, index) { + return QuestionContainerWidget( question: controller.mapQuestionListingToQuestionData(controller.questions[index], index), - answeredQuestion: controller.answers[index]); - }, + answeredQuestion: controller.answers[index], + ); + }, + ), ), - ), - ], - )), + ], + )), + ), ), ), ); } + Widget _buildCustomAppBar(BuildContext context) { + return Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.black), + onPressed: () { + controller.onPopInvoke(true, null); + }, + ), + const SizedBox(width: 8), + const Text( + 'Hasil Kuis', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black), + ), + ], + ); + } + Widget _buildScoreSummary() { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -73,77 +87,4 @@ class QuizResultView extends GetView { ], ); } - - // Widget _buildQuestionResult(int index) { - // final answered = controller.answers[index]; - // final isCorrect = answered.isCorrect; - - // return Container( - // width: double.infinity, - // margin: const EdgeInsets.only(bottom: 16), - // padding: const EdgeInsets.all(16), - // decoration: BoxDecoration( - // color: Colors.white, - // borderRadius: BorderRadius.circular(12), - // border: Border.all(color: AppColors.borderLight), - // boxShadow: [ - // BoxShadow( - // color: Colors.black.withOpacity(0.05), - // blurRadius: 6, - // offset: const Offset(2, 2), - // ), - // ], - // ), - // child: Column( - // crossAxisAlignment: CrossAxisAlignment.start, - // children: [ - // Text('Soal ${answered.index + 1}', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: AppColors.darkText)), - // const SizedBox(height: 6), - // Text( - // // Tidak ada tipe soal di AnsweredQuestion, jadi kalau mau kasih tipe harus pakai question[index].type - // // kalau tidak mau ribet, ini bisa dihapus saja - // // controller.mapQuestionTypeToText(controller.questions[answered.index].type), - // '', // kosongkan dulu - // style: const TextStyle(fontSize: 12, color: AppColors.softGrayText, fontStyle: FontStyle.italic), - // ), - // const SizedBox(height: 12), - // Text( - // answered.question, - // style: const TextStyle(fontSize: 16, color: AppColors.darkText), - // ), - // const SizedBox(height: 12), - // Row( - // crossAxisAlignment: CrossAxisAlignment.start, - // children: [ - // const Icon(Icons.person, size: 18, color: AppColors.primaryBlue), - // const SizedBox(width: 6), - // Expanded( - // child: Text( - // "Jawaban Kamu: ${answered.selectedAnswer}", - // style: TextStyle( - // color: isCorrect ? Colors.green : Colors.red, - // fontWeight: FontWeight.bold, - // ), - // ), - // ), - // ], - // ), - // const SizedBox(height: 4), - // Row( - // crossAxisAlignment: CrossAxisAlignment.start, - // children: [ - // const Icon(Icons.check, size: 18, color: AppColors.softGrayText), - // const SizedBox(width: 6), - // Expanded( - // child: Text( - // "Jawaban Benar: ${answered.correctAnswer}", - // style: const TextStyle(color: AppColors.darkText), - // ), - // ), - // ], - // ), - // ], - // ), - // ); - // } } From aa6b35f422b228d4a3ef4f3fefc1a0a387777d3a Mon Sep 17 00:00:00 2001 From: akhdanre Date: Wed, 30 Apr 2025 14:14:11 +0700 Subject: [PATCH 032/104] fix: interface and logic on the result --- devtools_options.yaml | 3 + .../notification/pop_up_confirmation.dart | 38 +++ .../widget/question_container_widget.dart | 279 +++++++++++------- .../controller/quiz_play_controller.dart | 95 +++--- .../quiz_play/view/quiz_play_view.dart | 214 ++++++++------ 5 files changed, 392 insertions(+), 237 deletions(-) create mode 100644 devtools_options.yaml diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/lib/component/notification/pop_up_confirmation.dart b/lib/component/notification/pop_up_confirmation.dart index c069e63..76fc147 100644 --- a/lib/component/notification/pop_up_confirmation.dart +++ b/lib/component/notification/pop_up_confirmation.dart @@ -2,6 +2,44 @@ import 'package:flutter/material.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; class AppDialog { + static Future showMessage(BuildContext context, String message) async { + await showDialog( + context: context, + barrierDismissible: true, + builder: (BuildContext context) { + return Dialog( + backgroundColor: AppColors.background, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.info_outline, + size: 40, + color: AppColors.primaryBlue, + ), + const SizedBox(height: 16), + Text( + message, + style: const TextStyle( + fontSize: 16, + color: AppColors.darkText, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + }, + ); + } + static Future showExitConfirmationDialog(BuildContext context) async { await showDialog( context: context, diff --git a/lib/component/widget/question_container_widget.dart b/lib/component/widget/question_container_widget.dart index 18b71d4..c53ee95 100644 --- a/lib/component/widget/question_container_widget.dart +++ b/lib/component/widget/question_container_widget.dart @@ -8,7 +8,11 @@ class QuestionContainerWidget extends StatelessWidget { final QuestionData question; final AnsweredQuestion? answeredQuestion; - const QuestionContainerWidget({super.key, required this.question, this.answeredQuestion}); + const QuestionContainerWidget({ + super.key, + required this.question, + this.answeredQuestion, + }); @override Widget build(BuildContext context) { @@ -16,103 +20,132 @@ class QuestionContainerWidget extends StatelessWidget { width: double.infinity, margin: const EdgeInsets.only(bottom: 20), padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.borderLight), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 6, - offset: const Offset(2, 2), - ), - ], - ), + decoration: _containerDecoration, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Soal ${question.index}', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: AppColors.darkText)), + _buildTitle(), const SizedBox(height: 6), - Text( - _mapQuestionTypeToText(question.type), - style: const TextStyle(fontSize: 12, color: AppColors.softGrayText, fontStyle: FontStyle.italic), - ), + _buildTypeLabel(), const SizedBox(height: 12), - Text( - question.question ?? '-', - style: const TextStyle(fontSize: 16, color: AppColors.darkText), - ), + _buildQuestionText(), const SizedBox(height: 16), - _buildAnswerSection(question), + _buildAnswerSection(), if (answeredQuestion != null) ...[ const SizedBox(height: 16), - _buildAnsweredSection(answeredQuestion!), + _buildAnsweredSection(question, answeredQuestion!), ], const SizedBox(height: 10), - Text( - 'Durasi: ${question.duration} detik', - style: TextStyle(fontSize: 14, color: AppColors.softGrayText), + _buildDurationInfo(), + ], + ), + ); + } + + // --- UI Builders --- + + Widget _buildTitle() => Text( + 'Soal ${question.index}', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: AppColors.darkText, + ), + ); + + Widget _buildTypeLabel() => Text( + _mapQuestionTypeToText(question.type), + style: const TextStyle( + fontSize: 12, + color: AppColors.softGrayText, + fontStyle: FontStyle.italic, + ), + ); + + Widget _buildQuestionText() => Text( + question.question ?? '-', + style: const TextStyle(fontSize: 16, color: AppColors.darkText), + ); + + Widget _buildAnswerSection() { + switch (question.type) { + case QuestionType.option: + return _buildOptionAnswers(); + case QuestionType.fillTheBlank: + return _buildFillInBlankAnswers(); + case QuestionType.trueOrFalse: + return _buildTrueFalseAnswer(); + default: + return const SizedBox(); + } + } + + Widget _buildOptionAnswers() { + final options = question.options ?? []; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: options.map((option) { + final isCorrect = option.index == question.correctAnswerIndex; + return _buildOptionItem(option.text, isCorrect); + }).toList(), + ); + } + + Widget _buildOptionItem(String text, bool isCorrect) { + return Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: isCorrect ? AppColors.primaryBlue.withOpacity(0.1) : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + isCorrect ? Icons.check_circle_rounded : Icons.circle_outlined, + size: 18, + color: isCorrect ? AppColors.primaryBlue : AppColors.softGrayText, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: TextStyle( + fontWeight: isCorrect ? FontWeight.bold : FontWeight.normal, + color: isCorrect ? AppColors.primaryBlue : AppColors.darkText, + ), + ), ), ], ), ); } - Widget _buildAnswerSection(QuestionData question) { - if (question.type == QuestionType.option && question.options != null) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: question.options!.map((option) { - bool isCorrect = question.correctAnswerIndex == option.index; - return Container( - margin: const EdgeInsets.symmetric(vertical: 4), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - decoration: BoxDecoration( - color: isCorrect ? AppColors.primaryBlue.withOpacity(0.1) : Colors.transparent, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Icon( - isCorrect ? Icons.check_circle_rounded : Icons.circle_outlined, - size: 18, - color: isCorrect ? AppColors.primaryBlue : AppColors.softGrayText, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - option.text, - style: TextStyle( - fontWeight: isCorrect ? FontWeight.bold : FontWeight.normal, - color: isCorrect ? AppColors.primaryBlue : AppColors.darkText, - ), - ), - ), - ], - ), - ); - }).toList(), - ); - } else if (question.type == QuestionType.fillTheBlank) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: _buildFillTheBlankPossibilities(question.answer ?? '-'), - ); - } else if (question.type == QuestionType.trueOrFalse) { - return Text( - 'Jawaban: ${question.answer ?? '-'}', - style: const TextStyle(color: AppColors.softGrayText), - ); - } else { - return const SizedBox(); - } + Widget _buildFillInBlankAnswers() { + final variations = _generateFillBlankVariations(question.answer ?? '-'); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: variations.map((text) => _buildBulletText(text)).toList(), + ); } - Widget _buildAnsweredSection(AnsweredQuestion answered) { + Widget _buildTrueFalseAnswer() { + return Text( + 'Jawaban: ${question.answer ?? '-'}', + style: const TextStyle(color: AppColors.softGrayText), + ); + } + + Widget _buildAnsweredSection(QuestionData question, AnsweredQuestion answered) { + String answer = question.type == QuestionType.option ? question.options![int.parse(answered.selectedAnswer)].text : answered.selectedAnswer; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Jawaban Anda:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: AppColors.darkText)), + const Text( + 'Jawaban Anda:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: AppColors.darkText), + ), const SizedBox(height: 6), Row( children: [ @@ -124,7 +157,7 @@ class QuestionContainerWidget extends StatelessWidget { const SizedBox(width: 8), Expanded( child: Text( - answered.selectedAnswer, + answer, style: TextStyle( fontSize: 14, fontWeight: FontWeight.bold, @@ -138,57 +171,75 @@ class QuestionContainerWidget extends StatelessWidget { ); } - String _mapQuestionTypeToText(QuestionType? type) { - switch (type) { - case QuestionType.option: - return 'Tipe: Pilihan Ganda'; - case QuestionType.fillTheBlank: - return 'Tipe: Isian Kosong'; - case QuestionType.trueOrFalse: - return 'Tipe: Benar / Salah'; - default: - return 'Tipe: Tidak diketahui'; - } + Widget _buildBulletText(String text) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + const Icon(Icons.arrow_right, size: 18, color: AppColors.softGrayText), + const SizedBox(width: 6), + Text( + text, + style: const TextStyle(color: AppColors.darkText), + ), + ], + ), + ); } - List _buildFillTheBlankPossibilities(String answer) { - List possibilities = [ + Widget _buildDurationInfo() { + String duration = question.duration.toString(); + if (answeredQuestion != null) duration = answeredQuestion!.duration.toString(); + + return Text( + 'Durasi: $duration detik', + style: const TextStyle(fontSize: 14, color: AppColors.softGrayText), + ); + } + + // --- Utils --- + + List _generateFillBlankVariations(String answer) { + return [ _capitalizeEachWord(answer), answer.toLowerCase(), _capitalizeFirstWordOnly(answer), ]; - - return possibilities.map((option) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Row( - children: [ - const Icon(Icons.arrow_right, size: 18, color: AppColors.softGrayText), - const SizedBox(width: 6), - Text( - option, - style: const TextStyle(color: AppColors.darkText), - ), - ], - ), - ); - }).toList(); } String _capitalizeEachWord(String text) { - return text.split(' ').map((word) { - if (word.isEmpty) return word; - return word[0].toUpperCase() + word.substring(1).toLowerCase(); - }).join(' '); + return text.split(' ').map((w) => w.isNotEmpty ? '${w[0].toUpperCase()}${w.substring(1).toLowerCase()}' : '').join(' '); } String _capitalizeFirstWordOnly(String text) { - if (text.isEmpty) return text; - List parts = text.split(' '); - parts[0] = parts[0][0].toUpperCase() + parts[0].substring(1).toLowerCase(); + final parts = text.split(' '); + if (parts.isEmpty) return text; + parts[0] = _capitalizeEachWord(parts[0]); for (int i = 1; i < parts.length; i++) { parts[i] = parts[i].toLowerCase(); } return parts.join(' '); } + + String _mapQuestionTypeToText(QuestionType? type) { + return switch (type) { + QuestionType.option => 'Tipe: Pilihan Ganda', + QuestionType.fillTheBlank => 'Tipe: Isian Kosong', + QuestionType.trueOrFalse => 'Tipe: Benar / Salah', + _ => 'Tipe: Tidak diketahui', + }; + } + + BoxDecoration get _containerDecoration => BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.borderLight), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 6, + offset: const Offset(2, 2), + ), + ], + ); } diff --git a/lib/feature/quiz_play/controller/quiz_play_controller.dart b/lib/feature/quiz_play/controller/quiz_play_controller.dart index 355fa0a..130f08c 100644 --- a/lib/feature/quiz_play/controller/quiz_play_controller.dart +++ b/lib/feature/quiz_play/controller/quiz_play_controller.dart @@ -3,22 +3,29 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/component/notification/pop_up_confirmation.dart'; import 'package:quiz_app/data/models/quiz/library_quiz_model.dart'; import 'package:quiz_app/data/models/quiz/question_listings_model.dart'; class QuizPlayController extends GetxController { late final QuizData quizData; + // State & UI final currentIndex = 0.obs; final timeLeft = 0.obs; final isStarting = true.obs; final isAnswerSelected = false.obs; + final prepareDuration = 3.obs; + + // Answer-related final selectedAnswer = ''.obs; final idxOptionSelected = (-1).obs; + final choosenAnswerTOF = 0.obs; final List answeredQuestions = []; + // Input controller final answerTextController = TextEditingController(); - final choosenAnswerTOF = 0.obs; + Timer? _timer; QuestionListing get currentQuestion => quizData.questionListings[currentIndex.value]; @@ -29,16 +36,12 @@ class QuizPlayController extends GetxController { quizData = Get.arguments as QuizData; _startCountdown(); - // Listener untuk fill the blank + // Listener untuk fill in the blank answerTextController.addListener(() { - if (answerTextController.text.trim().isNotEmpty) { - isAnswerSelected.value = true; - } else { - isAnswerSelected.value = false; - } + isAnswerSelected.value = answerTextController.text.trim().isNotEmpty; }); - // Listener untuk pilihan true/false + // Listener untuk true/false ever(choosenAnswerTOF, (value) { if (value != 0) { isAnswerSelected.value = true; @@ -47,14 +50,19 @@ class QuizPlayController extends GetxController { } void _startCountdown() async { - await Future.delayed(const Duration(seconds: 3)); isStarting.value = false; + for (int i = 3; i > 0; i--) { + prepareDuration.value = i; + await Future.delayed(const Duration(seconds: 1)); + } _startTimer(); + isStarting.value = true; } void _startTimer() { timeLeft.value = currentQuestion.duration; _timer?.cancel(); + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { if (timeLeft.value > 0) { timeLeft.value--; @@ -63,20 +71,16 @@ class QuizPlayController extends GetxController { _nextQuestion(); } }); + isAnswerSelected.value = false; } void selectAnswerOption(int selectedIndex) { selectedAnswer.value = selectedIndex.toString(); - isAnswerSelected.value = true; idxOptionSelected.value = selectedIndex; + isAnswerSelected.value = true; } - // void selectAnswerFTB(String answer) { - // selectedAnswer.value = answer; - // isAnswerSelected.value = true; - // } - void onChooseTOF(bool value) { choosenAnswerTOF.value = value ? 1 : 2; selectedAnswer.value = value.toString(); @@ -84,22 +88,24 @@ class QuizPlayController extends GetxController { void _submitAnswerIfNeeded() { final question = currentQuestion; - String userAnswer = ""; + String userAnswer = ''; - if (question.type == "fill_the_blank") { - userAnswer = answerTextController.text.trim(); - } else if (question.type == "option") { - userAnswer = selectedAnswer.value.trim(); - } else if (question.type == "true_false") { - userAnswer = selectedAnswer.value.trim(); + switch (question.type) { + case 'fill_the_blank': + userAnswer = answerTextController.text.trim(); + break; + case 'option': + case 'true_false': + userAnswer = selectedAnswer.value.trim(); + break; } - answeredQuestions.add(AnsweredQuestion( index: currentIndex.value, - question: question.question, + questionIndex: question.index, selectedAnswer: userAnswer, correctAnswer: question.targetAnswer.trim(), isCorrect: userAnswer.toLowerCase() == question.targetAnswer.trim().toLowerCase(), + duration: currentQuestion.duration - timeLeft.value, )); } @@ -110,20 +116,28 @@ class QuizPlayController extends GetxController { void _nextQuestion() { _timer?.cancel(); + if (currentIndex.value < quizData.questionListings.length - 1) { currentIndex.value++; - answerTextController.clear(); - selectedAnswer.value = ''; - choosenAnswerTOF.value = 0; + _resetAnswerState(); _startTimer(); } else { _finishQuiz(); } } - void _finishQuiz() { - _timer?.cancel(); + void _resetAnswerState() { + answerTextController.clear(); + selectedAnswer.value = ''; + choosenAnswerTOF.value = 0; + idxOptionSelected.value = -1; + isAnswerSelected.value = false; + } + void _finishQuiz() async { + _timer?.cancel(); + AppDialog.showMessage(Get.context!, "Yeay semua soal selesai"); + await Future.delayed(Duration(seconds: 2)); Get.offAllNamed( AppRoutes.resultQuizPage, arguments: [quizData, answeredQuestions], @@ -140,26 +154,27 @@ class QuizPlayController extends GetxController { class AnsweredQuestion { final int index; - final String question; + final int questionIndex; final String selectedAnswer; final String correctAnswer; final bool isCorrect; + final int duration; AnsweredQuestion({ required this.index, - required this.question, + required this.questionIndex, required this.selectedAnswer, required this.correctAnswer, required this.isCorrect, + required this.duration, }); - Map toJson() { - return { - 'index': index, - 'question': question, - 'selectedAnswer': selectedAnswer, - 'correctAnswer': correctAnswer, - 'isCorrect': isCorrect, - }; - } + Map toJson() => { + 'index': index, + 'question_index': questionIndex, + 'selectedAnswer': selectedAnswer, + 'correctAnswer': correctAnswer, + 'isCorrect': isCorrect, + 'duration': duration, + }; } diff --git a/lib/feature/quiz_play/view/quiz_play_view.dart b/lib/feature/quiz_play/view/quiz_play_view.dart index 46981e6..dc6ac95 100644 --- a/lib/feature/quiz_play/view/quiz_play_view.dart +++ b/lib/feature/quiz_play/view/quiz_play_view.dart @@ -11,99 +11,33 @@ class QuizPlayView extends GetView { Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFF9FAFB), - appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - automaticallyImplyLeading: false, - title: const Text( - 'Kerjakan Soal', - style: TextStyle(color: Colors.black, fontWeight: FontWeight.bold), - ), - iconTheme: const IconThemeData(color: Colors.black), - centerTitle: true, - ), + // appBar: _buildAppBar(), body: SafeArea( child: Padding( padding: const EdgeInsets.all(16), child: Obx(() { - final question = controller.currentQuestion; + if (!controller.isStarting.value) { + return Center( + child: Text( + "Ready in ${controller.prepareDuration}", + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + )); + } + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - LinearProgressIndicator( - value: controller.timeLeft.value / question.duration, - minHeight: 8, - backgroundColor: Colors.grey[300], - valueColor: const AlwaysStoppedAnimation(Color(0xFF2563EB)), - ), + _buildCustomAppBar(), const SizedBox(height: 20), - Text( - 'Soal ${controller.currentIndex.value + 1} dari ${controller.quizData.questionListings.length}', - style: const TextStyle( - fontSize: 16, - color: Colors.grey, - fontWeight: FontWeight.w500, - ), - ), + _buildProgressBar(), + const SizedBox(height: 20), + _buildQuestionIndicator(), const SizedBox(height: 12), - Text( - question.question, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Colors.black, - ), - ), + _buildQuestionText(), const SizedBox(height: 30), - - // Jawaban Berdasarkan Tipe Soal - if (question.type == 'option' && question.options != null) - ...List.generate(question.options!.length, (index) { - final option = question.options![index]; - return Container( - margin: const EdgeInsets.only(bottom: 12), - width: double.infinity, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: controller.idxOptionSelected.value == index ? AppColors.primaryBlue : Colors.white, - foregroundColor: controller.idxOptionSelected.value == index ? Colors.white : Colors.black, - side: const BorderSide(color: Colors.grey), - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - onPressed: () => controller.selectAnswerOption(index), - child: Text(option), - ), - ); - }) - else if (question.type == 'true_false') - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _buildTrueFalseButton('Ya', true, controller.choosenAnswerTOF), - _buildTrueFalseButton('Tidak', false, controller.choosenAnswerTOF), - ], - ) - else - GlobalTextField(controller: controller.answerTextController), + _buildAnswerSection(), const Spacer(), - Obx(() { - return ElevatedButton( - onPressed: controller.nextQuestion, - style: ElevatedButton.styleFrom( - backgroundColor: controller.isAnswerSelected.value ? const Color(0xFF2563EB) : Colors.grey, - foregroundColor: Colors.white, - minimumSize: const Size(double.infinity, 50), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: const Text( - 'Next', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), - ), - ); - }), + _buildNextButton(), ], ); }), @@ -112,9 +46,103 @@ class QuizPlayView extends GetView { ); } + Widget _buildCustomAppBar() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: const BoxDecoration( + color: Colors.transparent, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Text( + 'Kerjakan Soal', + style: TextStyle( + color: Colors.black, + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ), + ], + ), + ); + } + + Widget _buildProgressBar() { + final question = controller.currentQuestion; + return LinearProgressIndicator( + value: controller.timeLeft.value / question.duration, + minHeight: 8, + backgroundColor: Colors.grey[300], + valueColor: const AlwaysStoppedAnimation(Color(0xFF2563EB)), + ); + } + + Widget _buildQuestionIndicator() { + return Text( + 'Soal ${controller.currentIndex.value + 1} dari ${controller.quizData.questionListings.length}', + style: const TextStyle( + fontSize: 16, + color: Colors.grey, + fontWeight: FontWeight.w500, + ), + ); + } + + Widget _buildQuestionText() { + return Text( + controller.currentQuestion.question, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ); + } + + Widget _buildAnswerSection() { + final question = controller.currentQuestion; + + if (question.type == 'option' && question.options != null) { + return Column( + children: List.generate(question.options!.length, (index) { + final option = question.options![index]; + final isSelected = controller.idxOptionSelected.value == index; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: isSelected ? AppColors.primaryBlue : Colors.white, + foregroundColor: isSelected ? Colors.white : Colors.black, + side: const BorderSide(color: Colors.grey), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + onPressed: () => controller.selectAnswerOption(index), + child: Text(option), + ), + ); + }), + ); + } else if (question.type == 'true_false') { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildTrueFalseButton('Ya', true, controller.choosenAnswerTOF), + _buildTrueFalseButton('Tidak', false, controller.choosenAnswerTOF), + ], + ); + } else { + return GlobalTextField(controller: controller.answerTextController); + } + } + Widget _buildTrueFalseButton(String label, bool value, RxInt choosenAnswer) { return Obx(() { - bool isSelected = (choosenAnswer.value == (value ? 1 : 2)); + final isSelected = (choosenAnswer.value == (value ? 1 : 2)); + return ElevatedButton.icon( style: ElevatedButton.styleFrom( backgroundColor: isSelected ? (value ? Colors.green[100] : Colors.red[100]) : Colors.white, @@ -129,4 +157,24 @@ class QuizPlayView extends GetView { ); }); } + + Widget _buildNextButton() { + return Obx(() { + final isEnabled = controller.isAnswerSelected.value; + + return ElevatedButton( + onPressed: isEnabled ? controller.nextQuestion : null, + style: ElevatedButton.styleFrom( + backgroundColor: isEnabled ? const Color(0xFF2563EB) : Colors.grey, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: const Text( + 'Next', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ); + }); + } } From 14cd51c65bf1331d6a0f0f47f2248c86210c35df Mon Sep 17 00:00:00 2001 From: akhdanre Date: Thu, 1 May 2025 20:22:20 +0700 Subject: [PATCH 033/104] feat: working on the history binding --- lib/core/endpoint/api_endpoint.dart | 2 + lib/data/models/history/quiz_history.dart | 42 +++++++++++++++++ lib/data/services/answer_service.dart | 0 lib/data/services/history_service.dart | 31 +++++++++++++ .../history/binding/history_binding.dart | 5 ++- .../controller/history_controller.dart | 45 +++++-------------- lib/feature/history/view/history_view.dart | 7 +-- .../controller/quiz_play_controller.dart | 1 + 8 files changed, 95 insertions(+), 38 deletions(-) create mode 100644 lib/data/models/history/quiz_history.dart create mode 100644 lib/data/services/answer_service.dart create mode 100644 lib/data/services/history_service.dart diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart index 92f5238..8416918 100644 --- a/lib/core/endpoint/api_endpoint.dart +++ b/lib/core/endpoint/api_endpoint.dart @@ -8,4 +8,6 @@ class APIEndpoint { static const String quiz = "/quiz"; static const String userQuiz = "/quiz/user"; + + static const String historyQuiz = "/history"; } diff --git a/lib/data/models/history/quiz_history.dart b/lib/data/models/history/quiz_history.dart new file mode 100644 index 0000000..5a73af1 --- /dev/null +++ b/lib/data/models/history/quiz_history.dart @@ -0,0 +1,42 @@ +class QuizHistory { + final String quizId; + final String answerId; + final String title; + final String description; + final int totalCorrect; + final int totalQuestion; + final String date; + + QuizHistory({ + required this.quizId, + required this.answerId, + required this.title, + required this.description, + required this.totalCorrect, + required this.totalQuestion, + required this.date, + }); + + factory QuizHistory.fromJson(Map json) { + return QuizHistory( + quizId: json['quiz_id'], + answerId: json['answer_id'], + title: json['title'], + description: json['description'], + totalCorrect: json['total_correct'], + totalQuestion: json['total_question'], + date: json["date"]); + } + + Map toJson() { + return { + 'quiz_id': quizId, + 'answer_id': answerId, + 'title': title, + 'description': description, + 'total_correct': totalCorrect, + 'total_question': totalQuestion, + 'date': date + }; + } +} diff --git a/lib/data/services/answer_service.dart b/lib/data/services/answer_service.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/data/services/history_service.dart b/lib/data/services/history_service.dart new file mode 100644 index 0000000..1bfe06c --- /dev/null +++ b/lib/data/services/history_service.dart @@ -0,0 +1,31 @@ +import 'package:dio/dio.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/core/endpoint/api_endpoint.dart'; +import 'package:quiz_app/core/utils/logger.dart'; +import 'package:quiz_app/data/models/base/base_model.dart'; +import 'package:quiz_app/data/models/history/quiz_history.dart'; +import 'package:quiz_app/data/providers/dio_client.dart'; + +class HistoryService extends GetxService { + late final Dio _dio; + + @override + void onInit() { + _dio = Get.find().dio; + super.onInit(); + } + + Future?> getHistory(String userId) async { + try { + final result = await _dio.get("${APIEndpoint.historyQuiz}/$userId"); + + final parsedResponse = BaseResponseModel>.fromJson( + result.data, + (data) => (data as List).map((e) => QuizHistory.fromJson(e as Map)).toList(), + ); + return parsedResponse.data; + } catch (e, stacktrace) { + logC.e(e, stackTrace: stacktrace); + } + } +} diff --git a/lib/feature/history/binding/history_binding.dart b/lib/feature/history/binding/history_binding.dart index e9a4706..725ec3c 100644 --- a/lib/feature/history/binding/history_binding.dart +++ b/lib/feature/history/binding/history_binding.dart @@ -1,9 +1,12 @@ import 'package:get/get.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/services/history_service.dart'; import 'package:quiz_app/feature/history/controller/history_controller.dart'; class HistoryBinding extends Bindings { @override void dependencies() { - Get.lazyPut(() => HistoryController()); + Get.lazyPut(() => HistoryService()); + Get.lazyPut(() => HistoryController(Get.find(), Get.find())); } } diff --git a/lib/feature/history/controller/history_controller.dart b/lib/feature/history/controller/history_controller.dart index 8c79610..44a2f5d 100644 --- a/lib/feature/history/controller/history_controller.dart +++ b/lib/feature/history/controller/history_controller.dart @@ -1,23 +1,15 @@ import 'package:get/get.dart'; - -class HistoryItem { - final String title; - final String date; - final int score; - final int totalQuestions; - final String duration; - - HistoryItem({ - required this.title, - required this.date, - required this.score, - required this.totalQuestions, - required this.duration, - }); -} +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/models/history/quiz_history.dart'; +import 'package:quiz_app/data/services/history_service.dart'; class HistoryController extends GetxController { - final historyList = [].obs; + HistoryService _historyService; + UserController _userController; + + HistoryController(this._historyService, this._userController); + + final historyList = [].obs; @override void onInit() { @@ -25,22 +17,7 @@ class HistoryController extends GetxController { loadDummyHistory(); } - void loadDummyHistory() { - historyList.value = [ - HistoryItem( - title: "Fisika Dasar", - date: "24 April 2025", - score: 8, - totalQuestions: 10, - duration: "5m 21s", - ), - HistoryItem( - title: "Sejarah Indonesia", - date: "22 April 2025", - score: 7, - totalQuestions: 10, - duration: "4m 35s", - ), - ]; + void loadDummyHistory() async { + historyList.value = await _historyService.getHistory(_userController.userData!.id) ?? []; } } diff --git a/lib/feature/history/view/history_view.dart b/lib/feature/history/view/history_view.dart index d475e22..7bded36 100644 --- a/lib/feature/history/view/history_view.dart +++ b/lib/feature/history/view/history_view.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/data/models/history/quiz_history.dart'; import 'package:quiz_app/feature/history/controller/history_controller.dart'; class HistoryView extends GetView { @@ -60,7 +61,7 @@ class HistoryView extends GetView { ); } - Widget _buildHistoryCard(HistoryItem item) { + Widget _buildHistoryCard(QuizHistory item) { return Container( margin: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.all(16), @@ -106,14 +107,14 @@ class HistoryView extends GetView { const Icon(Icons.check_circle, size: 14, color: Colors.green), const SizedBox(width: 4), Text( - "Skor: ${item.score}/${item.totalQuestions}", + "Skor: ${item.totalCorrect}/${item.totalQuestion}", style: const TextStyle(fontSize: 12), ), const SizedBox(width: 16), const Icon(Icons.timer, size: 14, color: Colors.grey), const SizedBox(width: 4), Text( - item.duration, + "3 menit", style: const TextStyle(fontSize: 12), ), ], diff --git a/lib/feature/quiz_play/controller/quiz_play_controller.dart b/lib/feature/quiz_play/controller/quiz_play_controller.dart index 130f08c..9e28bf6 100644 --- a/lib/feature/quiz_play/controller/quiz_play_controller.dart +++ b/lib/feature/quiz_play/controller/quiz_play_controller.dart @@ -136,6 +136,7 @@ class QuizPlayController extends GetxController { void _finishQuiz() async { _timer?.cancel(); + AppDialog.showMessage(Get.context!, "Yeay semua soal selesai"); await Future.delayed(Duration(seconds: 2)); Get.offAllNamed( From cf9483834e10ef6bbc234b88cd6156af82dd0e0e Mon Sep 17 00:00:00 2001 From: akhdanre Date: Fri, 2 May 2025 00:16:45 +0700 Subject: [PATCH 034/104] feat: search endpoint --- lib/component/quiz_container_component.dart | 12 ++-- .../widget/recomendation_component.dart | 66 +++++++++++++++++++ lib/core/endpoint/api_endpoint.dart | 2 + lib/data/models/quiz/quiz_listing_model.dart | 39 +++++++++++ lib/data/services/history_service.dart | 1 + lib/data/services/quiz_service.dart | 42 ++++++++++++ lib/feature/home/binding/home_binding.dart | 4 +- .../home/controller/home_controller.dart | 20 ++++++ .../component/recomendation_component.dart | 36 ---------- lib/feature/home/view/home_page.dart | 9 ++- .../search/binding/search_binding.dart | 6 +- .../search/controller/search_controller.dart | 31 +++++++++ lib/feature/search/view/search_view.dart | 56 +++++++--------- 13 files changed, 245 insertions(+), 79 deletions(-) create mode 100644 lib/component/widget/recomendation_component.dart create mode 100644 lib/data/models/quiz/quiz_listing_model.dart delete mode 100644 lib/feature/home/view/component/recomendation_component.dart diff --git a/lib/component/quiz_container_component.dart b/lib/component/quiz_container_component.dart index b70cb57..2e09756 100644 --- a/lib/component/quiz_container_component.dart +++ b/lib/component/quiz_container_component.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; class QuizContainerComponent extends StatelessWidget { - const QuizContainerComponent({super.key}); + final QuizListingModel data; + const QuizContainerComponent({required this.data, super.key}); @override Widget build(BuildContext context) { @@ -40,8 +42,8 @@ class QuizContainerComponent extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - "Physics", + Text( + data.title, style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, @@ -49,8 +51,8 @@ class QuizContainerComponent extends StatelessWidget { ), ), const SizedBox(height: 4), - const Text( - "created by Akhdan Rabbani", + Text( + "created by ${data.authorName}", style: TextStyle( fontSize: 12, color: Color(0xFF6B778C), diff --git a/lib/component/widget/recomendation_component.dart b/lib/component/widget/recomendation_component.dart new file mode 100644 index 0000000..3cfb4a1 --- /dev/null +++ b/lib/component/widget/recomendation_component.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:quiz_app/component/quiz_container_component.dart'; +import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; + +class RecomendationComponent extends StatelessWidget { + final String title; + final List datas; + const RecomendationComponent({ + required this.title, + required this.datas, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionTitle(title), + const SizedBox(height: 10), + datas.isNotEmpty + // ? Text("yeay ${datas.length}") + ? ListView.builder( + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + itemCount: datas.length, + itemBuilder: (context, index) => QuizContainerComponent(data: datas[index]), + ) + : SizedBox.shrink() + ], + ); + } + + // Widget _label() { + // return const Padding( + // padding: EdgeInsets.symmetric(horizontal: 16), + // child: Text( + // "Quiz Recommendation", + // style: TextStyle( + // fontSize: 18, + // fontWeight: FontWeight.bold, + // color: Color(0xFF172B4D), // dark text + // ), + // ), + // ); + // } + + Widget _buildSectionTitle(String title) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + Text( + "Lihat semua", + style: TextStyle(fontSize: 14, color: Colors.blue.shade700), + ), + ], + ), + ); + } +} diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart index 8416918..e1c5aec 100644 --- a/lib/core/endpoint/api_endpoint.dart +++ b/lib/core/endpoint/api_endpoint.dart @@ -8,6 +8,8 @@ class APIEndpoint { static const String quiz = "/quiz"; static const String userQuiz = "/quiz/user"; + static const String quizRecomendation = "/quiz/recomendation"; + static const String quizSearch = "/quiz/search"; static const String historyQuiz = "/history"; } diff --git a/lib/data/models/quiz/quiz_listing_model.dart b/lib/data/models/quiz/quiz_listing_model.dart new file mode 100644 index 0000000..7f9bfc6 --- /dev/null +++ b/lib/data/models/quiz/quiz_listing_model.dart @@ -0,0 +1,39 @@ +class QuizListingModel { + final String quizId; + final String authorId; + final String authorName; + final String title; + final String description; + final String date; + + QuizListingModel({ + required this.quizId, + required this.authorId, + required this.authorName, + required this.title, + required this.description, + required this.date, + }); + + factory QuizListingModel.fromJson(Map json) { + return QuizListingModel( + quizId: json['quiz_id'] as String, + authorId: json['author_id'] as String, + authorName: json['author_name'] as String, + title: json['title'] as String, + description: json['description'] as String, + date: json['date'] as String, + ); + } + + Map toJson() { + return { + 'quiz_id': quizId, + 'author_id': authorId, + 'author_name': authorName, + 'title': title, + 'description': description, + 'date': date, + }; + } +} diff --git a/lib/data/services/history_service.dart b/lib/data/services/history_service.dart index 1bfe06c..fa200f7 100644 --- a/lib/data/services/history_service.dart +++ b/lib/data/services/history_service.dart @@ -26,6 +26,7 @@ class HistoryService extends GetxService { return parsedResponse.data; } catch (e, stacktrace) { logC.e(e, stackTrace: stacktrace); + return null; } } } diff --git a/lib/data/services/quiz_service.dart b/lib/data/services/quiz_service.dart index f1397ba..8a46eaa 100644 --- a/lib/data/services/quiz_service.dart +++ b/lib/data/services/quiz_service.dart @@ -5,6 +5,8 @@ import 'package:quiz_app/core/utils/logger.dart'; import 'package:quiz_app/data/models/base/base_model.dart'; import 'package:quiz_app/data/models/quiz/library_quiz_model.dart'; import 'package:quiz_app/data/models/quiz/question_create_request.dart'; +import 'package:quiz_app/data/models/quiz/question_listings_model.dart'; +import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; import 'package:quiz_app/data/providers/dio_client.dart'; class QuizService extends GetxService { @@ -49,4 +51,44 @@ class QuizService extends GetxService { return []; } } + + Future>?> recomendationQuiz() async { + try { + final response = await _dio.get(APIEndpoint.quizRecomendation); + + if (response.statusCode == 200) { + final parsedResponse = BaseResponseModel>.fromJson( + response.data, + (data) => (data as List).map((e) => QuizListingModel.fromJson(e as Map)).toList(), + ); + return parsedResponse; + } else { + logC.e("Failed to fetch recommendation quizzes. Status: ${response.statusCode}"); + return null; + } + } catch (e) { + logC.e("Error fetching recommendation quizzes: $e"); + return null; + } + } + + Future>?> searchQuiz(String keyword) async { + try { + final response = await _dio.get("${APIEndpoint.quizSearch}?keyword=$keyword&page=1&limit=10"); + + if (response.statusCode == 200) { + final parsedResponse = BaseResponseModel>.fromJson( + response.data, + (data) => (data as List).map((e) => QuizListingModel.fromJson(e as Map)).toList(), + ); + return parsedResponse; + } else { + logC.e("Failed to fetch search quizzes. Status: ${response.statusCode}"); + return null; + } + } catch (e) { + logC.e("Error fetching search quizzes: $e"); + return null; + } + } } diff --git a/lib/feature/home/binding/home_binding.dart b/lib/feature/home/binding/home_binding.dart index 9adecc5..53a5d45 100644 --- a/lib/feature/home/binding/home_binding.dart +++ b/lib/feature/home/binding/home_binding.dart @@ -1,9 +1,11 @@ import 'package:get/get.dart'; +import 'package:quiz_app/data/services/quiz_service.dart'; import 'package:quiz_app/feature/home/controller/home_controller.dart'; class HomeBinding extends Bindings { @override void dependencies() { - Get.lazyPut(() => HomeController()); + Get.lazyPut(() => QuizService()); + Get.lazyPut(() => HomeController(Get.find())); } } diff --git a/lib/feature/home/controller/home_controller.dart b/lib/feature/home/controller/home_controller.dart index 862f335..0b74bac 100644 --- a/lib/feature/home/controller/home_controller.dart +++ b/lib/feature/home/controller/home_controller.dart @@ -1,12 +1,32 @@ import 'package:get/get.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/models/base/base_model.dart'; +import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; +import 'package:quiz_app/data/services/quiz_service.dart'; class HomeController extends GetxController { final UserController _userController = Get.find(); + QuizService _quizService; + HomeController(this._quizService); Rx get userName => _userController.userName; Rx get userImage => _userController.userImage; + RxList data = [].obs; + void goToQuizCreation() => Get.toNamed(AppRoutes.quizCreatePage); + + @override + void onInit() { + _getRecomendationQuiz(); + super.onInit(); + } + + void _getRecomendationQuiz() async { + BaseResponseModel? response = await _quizService.recomendationQuiz(); + if (response != null) { + data.assignAll(response.data as List); + } + } } diff --git a/lib/feature/home/view/component/recomendation_component.dart b/lib/feature/home/view/component/recomendation_component.dart deleted file mode 100644 index a0e4db4..0000000 --- a/lib/feature/home/view/component/recomendation_component.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:quiz_app/component/quiz_container_component.dart'; - -class RecomendationComponent extends StatelessWidget { - const RecomendationComponent({super.key}); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _label(), - const SizedBox(height: 10), - QuizContainerComponent(), - const SizedBox(height: 10), - QuizContainerComponent(), - const SizedBox(height: 10), - QuizContainerComponent() - ], - ); - } - - Widget _label() { - return const Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: Text( - "Quiz Recommendation", - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Color(0xFF172B4D), // dark text - ), - ), - ); - } -} diff --git a/lib/feature/home/view/home_page.dart b/lib/feature/home/view/home_page.dart index a696a49..5874e55 100644 --- a/lib/feature/home/view/home_page.dart +++ b/lib/feature/home/view/home_page.dart @@ -3,7 +3,7 @@ import 'package:get/get.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; import 'package:quiz_app/feature/home/controller/home_controller.dart'; import 'package:quiz_app/feature/home/view/component/button_option.dart'; -import 'package:quiz_app/feature/home/view/component/recomendation_component.dart'; +import 'package:quiz_app/component/widget/recomendation_component.dart'; import 'package:quiz_app/feature/home/view/component/search_component.dart'; import 'package:quiz_app/feature/home/view/component/user_gretings.dart'; @@ -43,7 +43,12 @@ class HomeView extends GetView { children: [ SearchComponent(), const SizedBox(height: 20), - RecomendationComponent(), + Obx( + () => RecomendationComponent( + title: "Quiz Rekomendasi", + datas: controller.data.toList(), + ), + ), ], ), ), diff --git a/lib/feature/search/binding/search_binding.dart b/lib/feature/search/binding/search_binding.dart index 8ea4ccf..2d2c0f4 100644 --- a/lib/feature/search/binding/search_binding.dart +++ b/lib/feature/search/binding/search_binding.dart @@ -1,9 +1,13 @@ import 'package:get/get.dart'; +import 'package:quiz_app/data/services/quiz_service.dart'; import 'package:quiz_app/feature/search/controller/search_controller.dart'; class SearchBinding extends Bindings { @override void dependencies() { - Get.lazyPut(() => SearchQuizController()); + if (!Get.isRegistered()) { + Get.lazyPut(() => QuizService()); + } + Get.lazyPut(() => SearchQuizController(Get.find())); } } diff --git a/lib/feature/search/controller/search_controller.dart b/lib/feature/search/controller/search_controller.dart index f8c5808..da1e848 100644 --- a/lib/feature/search/controller/search_controller.dart +++ b/lib/feature/search/controller/search_controller.dart @@ -1,16 +1,47 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/data/models/base/base_model.dart'; +import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; +import 'package:quiz_app/data/services/quiz_service.dart'; class SearchQuizController extends GetxController { + final QuizService _quizService; + + SearchQuizController(this._quizService); + final searchController = TextEditingController(); final searchText = ''.obs; + RxList recommendationQData = [].obs; + RxList searchQData = [].obs; + @override void onInit() { + getRecomendation(); super.onInit(); searchController.addListener(() { searchText.value = searchController.text; }); + debounce( + searchText, + (value) => getSearchData(value), + time: Duration(seconds: 2), + ); + } + + void getRecomendation() async { + BaseResponseModel? response = await _quizService.recomendationQuiz(); + if (response != null) { + recommendationQData.assignAll(response.data as List); + } + } + + void getSearchData(String keyword) async { + searchQData.clear(); + BaseResponseModel? response = await _quizService.searchQuiz(keyword); + if (response != null) { + searchQData.assignAll(response.data); + } } @override diff --git a/lib/feature/search/view/search_view.dart b/lib/feature/search/view/search_view.dart index b985385..248d695 100644 --- a/lib/feature/search/view/search_view.dart +++ b/lib/feature/search/view/search_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/component/quiz_container_component.dart'; +import 'package:quiz_app/component/widget/recomendation_component.dart'; import 'package:quiz_app/feature/search/controller/search_controller.dart'; class SearchView extends GetView { @@ -15,7 +16,6 @@ class SearchView extends GetView { padding: const EdgeInsets.all(16), child: Obx(() { final isSearching = controller.searchText.isNotEmpty; - return ListView( children: [ _buildSearchBar(), @@ -23,20 +23,24 @@ class SearchView extends GetView { if (isSearching) ...[ _buildCategoryFilter(), const SizedBox(height: 20), - const Text( - "Result", - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), - ), - const SizedBox(height: 10), - _buildQuizList(count: 5), + + ...controller.searchQData.map( + (e) => QuizContainerComponent(data: e), + ) ] else ...[ - _buildSectionTitle("Rekomendasi Quiz"), - const SizedBox(height: 10), - _buildQuizList(), + Obx( + () => RecomendationComponent( + title: "Quiz Rekomendasi", + datas: controller.recommendationQData.toList(), + ), + ), const SizedBox(height: 30), - _buildSectionTitle("Quiz Populer"), - const SizedBox(height: 10), - _buildQuizList(), + Obx( + () => RecomendationComponent( + title: "Quiz Populer", + datas: controller.recommendationQData.toList(), + ), + ), ], ], ); @@ -87,25 +91,9 @@ class SearchView extends GetView { ); } - Widget _buildSectionTitle(String title) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - title, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - Text( - "Lihat semua", - style: TextStyle(fontSize: 14, color: Colors.blue.shade700), - ), - ], - ); - } - - Widget _buildQuizList({int count = 3}) { - return Column( - children: List.generate(count, (_) => const QuizContainerComponent()), - ); - } + // Widget _buildQuizList({int count = 3}) { + // return Column( + // children: List.generate(count, (_) => const QuizContainerComponent()), + // ); + // } } From c026a53d6f69d82b4a67c23e987911c6e70cb2c2 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Fri, 2 May 2025 03:09:22 +0700 Subject: [PATCH 035/104] fix: detail quiz --- .vscode/launch.json | 25 +++ lib/app/routes/app_pages.dart | 4 +- lib/component/quiz_container_component.dart | 146 +++++++------- lib/component/widget/loading_widget.dart | 22 +++ .../widget/recomendation_component.dart | 7 +- lib/data/services/quiz_service.dart | 21 +- .../binding/detail_quiz_binding.dart | 13 ++ .../controller/detail_quiz_controller.dart | 40 ++++ .../detail_quiz/view/detail_quix_view.dart | 181 ++++++++++++++++++ lib/feature/history/view/history_view.dart | 8 +- .../home/controller/home_controller.dart | 2 + lib/feature/home/view/home_page.dart | 1 + .../library/binding/detail_quiz_binding.dart | 9 - .../library/binding/library_binding.dart | 5 +- .../controller/detail_quiz_controller.dart | 18 -- .../library/view/detail_quix_view.dart | 176 ----------------- lib/feature/library/view/library_view.dart | 15 +- .../search/controller/search_controller.dart | 3 + lib/feature/search/view/search_view.dart | 5 +- 19 files changed, 400 insertions(+), 301 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 lib/component/widget/loading_widget.dart create mode 100644 lib/feature/detail_quiz/binding/detail_quiz_binding.dart create mode 100644 lib/feature/detail_quiz/controller/detail_quiz_controller.dart create mode 100644 lib/feature/detail_quiz/view/detail_quix_view.dart delete mode 100644 lib/feature/library/binding/detail_quiz_binding.dart delete mode 100644 lib/feature/library/controller/detail_quiz_controller.dart delete mode 100644 lib/feature/library/view/detail_quix_view.dart diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..5706166 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,25 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "quiz_app", + "request": "launch", + "type": "dart" + }, + // { + // "name": "quiz_app (profile mode)", + // "request": "launch", + // "type": "dart", + // "flutterMode": "profile" + // }, + // { + // "name": "quiz_app (release mode)", + // "request": "launch", + // "type": "dart", + // "flutterMode": "release" + // } + ] +} \ No newline at end of file diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index c9f278f..7d3ada7 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -3,9 +3,9 @@ import 'package:quiz_app/app/middleware/auth_middleware.dart'; import 'package:quiz_app/feature/history/binding/history_binding.dart'; import 'package:quiz_app/feature/home/binding/home_binding.dart'; import 'package:quiz_app/feature/home/view/home_page.dart'; -import 'package:quiz_app/feature/library/binding/detail_quiz_binding.dart'; +import 'package:quiz_app/feature/detail_quiz/binding/detail_quiz_binding.dart'; import 'package:quiz_app/feature/library/binding/library_binding.dart'; -import 'package:quiz_app/feature/library/view/detail_quix_view.dart'; +import 'package:quiz_app/feature/detail_quiz/view/detail_quix_view.dart'; import 'package:quiz_app/feature/login/bindings/login_binding.dart'; import 'package:quiz_app/feature/login/view/login_page.dart'; import 'package:quiz_app/feature/navigation/bindings/navigation_binding.dart'; diff --git a/lib/component/quiz_container_component.dart b/lib/component/quiz_container_component.dart index 2e09756..137ad6c 100644 --- a/lib/component/quiz_container_component.dart +++ b/lib/component/quiz_container_component.dart @@ -4,82 +4,86 @@ import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; class QuizContainerComponent extends StatelessWidget { final QuizListingModel data; - const QuizContainerComponent({required this.data, super.key}); + final Function(String) onTap; + const QuizContainerComponent({required this.data, required this.onTap, super.key}); @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: AppColors.background, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Color(0xFFE1E4E8), - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.03), - blurRadius: 8, - offset: Offset(0, 2), - ) - ], - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 50, - height: 50, - decoration: BoxDecoration( - color: Color(0xFF0052CC), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon(Icons.school, color: Colors.white, size: 28), + return GestureDetector( + onTap: () => onTap(data.quizId), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Color(0xFFE1E4E8), ), - const SizedBox(width: 12), - // Quiz Info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - data.title, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Color(0xFF172B4D), - ), - ), - const SizedBox(height: 4), - Text( - "created by ${data.authorName}", - style: TextStyle( - fontSize: 12, - color: Color(0xFF6B778C), - ), - ), - const SizedBox(height: 8), - Row( - children: const [ - Icon(Icons.format_list_bulleted, size: 14, color: Color(0xFF6B778C)), - SizedBox(width: 4), - Text( - "50 Quizzes", - style: TextStyle(fontSize: 12, color: Color(0xFF6B778C)), - ), - SizedBox(width: 12), - Icon(Icons.access_time, size: 14, color: Color(0xFF6B778C)), - SizedBox(width: 4), - Text( - "1 hr duration", - style: TextStyle(fontSize: 12, color: Color(0xFF6B778C)), - ), - ], - ) - ], + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 8, + offset: Offset(0, 2), + ) + ], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: Color(0xFF0052CC), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.school, color: Colors.white, size: 28), ), - ) - ], + const SizedBox(width: 12), + // Quiz Info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data.title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF172B4D), + ), + ), + const SizedBox(height: 4), + Text( + "created by ${data.authorName}", + style: TextStyle( + fontSize: 12, + color: Color(0xFF6B778C), + ), + ), + const SizedBox(height: 8), + Row( + children: const [ + Icon(Icons.format_list_bulleted, size: 14, color: Color(0xFF6B778C)), + SizedBox(width: 4), + Text( + "50 Quizzes", + style: TextStyle(fontSize: 12, color: Color(0xFF6B778C)), + ), + SizedBox(width: 12), + Icon(Icons.access_time, size: 14, color: Color(0xFF6B778C)), + SizedBox(width: 4), + Text( + "1 hr duration", + style: TextStyle(fontSize: 12, color: Color(0xFF6B778C)), + ), + ], + ) + ], + ), + ) + ], + ), ), ); } diff --git a/lib/component/widget/loading_widget.dart b/lib/component/widget/loading_widget.dart new file mode 100644 index 0000000..35b24c8 --- /dev/null +++ b/lib/component/widget/loading_widget.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; + +class LoadingWidget extends StatelessWidget { + const LoadingWidget({super.key}); + + @override + Widget build(BuildContext context) { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 12), + Text( + "Memuat data...", + style: TextStyle(color: Colors.grey, fontSize: 14), + ), + ], + ), + ); + } +} diff --git a/lib/component/widget/recomendation_component.dart b/lib/component/widget/recomendation_component.dart index 3cfb4a1..2cd43af 100644 --- a/lib/component/widget/recomendation_component.dart +++ b/lib/component/widget/recomendation_component.dart @@ -5,9 +5,11 @@ import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; class RecomendationComponent extends StatelessWidget { final String title; final List datas; + final Function(String) itemOnTap; const RecomendationComponent({ required this.title, required this.datas, + required this.itemOnTap, super.key, }); @@ -24,7 +26,10 @@ class RecomendationComponent extends StatelessWidget { shrinkWrap: true, physics: NeverScrollableScrollPhysics(), itemCount: datas.length, - itemBuilder: (context, index) => QuizContainerComponent(data: datas[index]), + itemBuilder: (context, index) => QuizContainerComponent( + data: datas[index], + onTap: itemOnTap, + ), ) : SizedBox.shrink() ], diff --git a/lib/data/services/quiz_service.dart b/lib/data/services/quiz_service.dart index 8a46eaa..94a1948 100644 --- a/lib/data/services/quiz_service.dart +++ b/lib/data/services/quiz_service.dart @@ -5,7 +5,6 @@ import 'package:quiz_app/core/utils/logger.dart'; import 'package:quiz_app/data/models/base/base_model.dart'; import 'package:quiz_app/data/models/quiz/library_quiz_model.dart'; import 'package:quiz_app/data/models/quiz/question_create_request.dart'; -import 'package:quiz_app/data/models/quiz/question_listings_model.dart'; import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; import 'package:quiz_app/data/providers/dio_client.dart'; @@ -91,4 +90,24 @@ class QuizService extends GetxService { return null; } } + + Future?> getQuizById(String quizId) async { + try { + final response = await _dio.get("${APIEndpoint.quiz}/$quizId"); + + if (response.statusCode == 200) { + final parsedResponse = BaseResponseModel.fromJson( + response.data, + (data) => QuizData.fromJson(data), + ); + return parsedResponse; + } else { + logC.e("Failed to fetch quiz by id. Status: ${response.statusCode}"); + return null; + } + } catch (e) { + logC.e("Error fetching quiz by id $e"); + return null; + } + } } diff --git a/lib/feature/detail_quiz/binding/detail_quiz_binding.dart b/lib/feature/detail_quiz/binding/detail_quiz_binding.dart new file mode 100644 index 0000000..500837a --- /dev/null +++ b/lib/feature/detail_quiz/binding/detail_quiz_binding.dart @@ -0,0 +1,13 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/data/services/quiz_service.dart'; +import 'package:quiz_app/feature/detail_quiz/controller/detail_quiz_controller.dart'; + +class DetailQuizBinding extends Bindings { + @override + void dependencies() { + if (!Get.isRegistered()) { + Get.lazyPut(() => QuizService()); + } + Get.lazyPut(() => DetailQuizController(Get.find())); + } +} diff --git a/lib/feature/detail_quiz/controller/detail_quiz_controller.dart b/lib/feature/detail_quiz/controller/detail_quiz_controller.dart new file mode 100644 index 0000000..a4ecddf --- /dev/null +++ b/lib/feature/detail_quiz/controller/detail_quiz_controller.dart @@ -0,0 +1,40 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/data/models/base/base_model.dart'; +import 'package:quiz_app/data/models/quiz/library_quiz_model.dart'; +import 'package:quiz_app/data/services/quiz_service.dart'; + +class DetailQuizController extends GetxController { + final QuizService _quizService; + + DetailQuizController(this._quizService); + + RxBool isLoading = true.obs; + + late QuizData data; + + @override + void onInit() { + super.onInit(); + loadData(); + } + + void loadData() async { + final args = Get.arguments; + if (args is QuizData) { + data = args; + } else { + getQuizData(args); + } + } + + void getQuizData(String quizId) async { + BaseResponseModel? response = await _quizService.getQuizById(quizId); + if (response != null) { + data = response.data; + } + isLoading.value = false; + } + + void goToPlayPage() => Get.toNamed(AppRoutes.playQuizPage, arguments: data); +} diff --git a/lib/feature/detail_quiz/view/detail_quix_view.dart b/lib/feature/detail_quiz/view/detail_quix_view.dart new file mode 100644 index 0000000..a178585 --- /dev/null +++ b/lib/feature/detail_quiz/view/detail_quix_view.dart @@ -0,0 +1,181 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/component/global_button.dart'; +import 'package:quiz_app/component/widget/loading_widget.dart'; +import 'package:quiz_app/data/models/quiz/question_listings_model.dart'; +import 'package:quiz_app/feature/detail_quiz/controller/detail_quiz_controller.dart'; + +class DetailQuizView extends GetView { + const DetailQuizView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.background, + elevation: 0, + title: const Text( + 'Detail Quiz', + style: TextStyle( + color: AppColors.darkText, + fontWeight: FontWeight.bold, + ), + ), + centerTitle: true, + iconTheme: const IconThemeData(color: AppColors.darkText), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(20), + child: Obx( + () => controller.isLoading.value + ? Center(child: LoadingWidget()) + : SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Section + Text( + controller.data.title, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: AppColors.darkText, + ), + ), + const SizedBox(height: 8), + Text( + controller.data.description ?? "", + style: const TextStyle( + fontSize: 14, + color: AppColors.softGrayText, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + const Icon(Icons.calendar_today_rounded, size: 16, color: AppColors.softGrayText), + const SizedBox(width: 6), + Text( + controller.data.date ?? "", + style: const TextStyle(fontSize: 12, color: AppColors.softGrayText), + ), + const SizedBox(width: 12), + const Icon(Icons.timer_rounded, size: 16, color: AppColors.softGrayText), + const SizedBox(width: 6), + Text( + '${controller.data.limitDuration ~/ 60} menit', + style: const TextStyle(fontSize: 12, color: AppColors.softGrayText), + ), + ], + ), + const SizedBox(height: 20), + const SizedBox(height: 20), + + GlobalButton(text: "Kerjakan", onPressed: controller.goToPlayPage), + const SizedBox(height: 20), + GlobalButton(text: "buat ruangan", onPressed: () {}), + + const SizedBox(height: 20), + const Divider(thickness: 1.2, color: AppColors.borderLight), + const SizedBox(height: 20), + // Soal Section + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: controller.data.questionListings.length, + itemBuilder: (context, index) { + final question = controller.data.questionListings[index]; + return _buildQuestionItem(question, index + 1); + }, + ), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildQuestionItem(QuestionListing question, int index) { + return Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.borderLight), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 6, + offset: const Offset(2, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Soal $index', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: AppColors.darkText, + ), + ), + const SizedBox(height: 8), + Text( + _mapQuestionTypeToText(question.type), + style: const TextStyle( + fontSize: 12, + fontStyle: FontStyle.italic, + color: AppColors.softGrayText, + ), + ), + const SizedBox(height: 12), + Text( + question.question, + style: const TextStyle( + fontSize: 14, + color: AppColors.darkText, + ), + ), + // const SizedBox(height: 12), + // Text( + // 'Jawaban: ${question.targetAnswer}', + // style: const TextStyle( + // fontSize: 14, + // color: AppColors.softGrayText, + // ), + // ), + const SizedBox(height: 8), + Text( + 'Durasi: ${question.duration} detik', + style: const TextStyle( + fontSize: 12, + color: AppColors.softGrayText, + ), + ), + ], + ), + ); + } + + String _mapQuestionTypeToText(String? type) { + switch (type) { + case 'option': + return 'Pilihan Ganda'; + case 'fill_the_blank': + return 'Isian Kosong'; + case 'true_false': + return 'Benar / Salah'; + default: + return 'Tipe Tidak Diketahui'; + } + } +} diff --git a/lib/feature/history/view/history_view.dart b/lib/feature/history/view/history_view.dart index 7bded36..2b9a09e 100644 --- a/lib/feature/history/view/history_view.dart +++ b/lib/feature/history/view/history_view.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/component/widget/loading_widget.dart'; import 'package:quiz_app/data/models/history/quiz_history.dart'; import 'package:quiz_app/feature/history/controller/history_controller.dart'; @@ -36,12 +37,7 @@ class HistoryView extends GetView { const SizedBox(height: 20), if (historyList.isEmpty) const Expanded( - child: Center( - child: Text( - "Belum ada kuis yang dikerjakan.", - style: TextStyle(fontSize: 16, color: Colors.grey), - ), - ), + child: Center(child: LoadingWidget()), ) else Expanded( diff --git a/lib/feature/home/controller/home_controller.dart b/lib/feature/home/controller/home_controller.dart index 0b74bac..bc8b970 100644 --- a/lib/feature/home/controller/home_controller.dart +++ b/lib/feature/home/controller/home_controller.dart @@ -29,4 +29,6 @@ class HomeController extends GetxController { data.assignAll(response.data as List); } } + + void onRecommendationTap(String quizId) => Get.toNamed(AppRoutes.detailQuizPage, arguments: quizId); } diff --git a/lib/feature/home/view/home_page.dart b/lib/feature/home/view/home_page.dart index 5874e55..932453a 100644 --- a/lib/feature/home/view/home_page.dart +++ b/lib/feature/home/view/home_page.dart @@ -47,6 +47,7 @@ class HomeView extends GetView { () => RecomendationComponent( title: "Quiz Rekomendasi", datas: controller.data.toList(), + itemOnTap: controller.onRecommendationTap, ), ), ], diff --git a/lib/feature/library/binding/detail_quiz_binding.dart b/lib/feature/library/binding/detail_quiz_binding.dart deleted file mode 100644 index 684c18c..0000000 --- a/lib/feature/library/binding/detail_quiz_binding.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:get/get.dart'; -import 'package:quiz_app/feature/library/controller/detail_quiz_controller.dart'; - -class DetailQuizBinding extends Bindings { - @override - void dependencies() { - Get.lazyPut(() => DetailQuizController()); - } -} diff --git a/lib/feature/library/binding/library_binding.dart b/lib/feature/library/binding/library_binding.dart index 854c38f..6bdaf97 100644 --- a/lib/feature/library/binding/library_binding.dart +++ b/lib/feature/library/binding/library_binding.dart @@ -6,8 +6,9 @@ import 'package:quiz_app/feature/library/controller/library_controller.dart'; class LibraryBinding extends Bindings { @override void dependencies() { - Get.lazyPut(() => QuizService()); - + if (!Get.isRegistered()) { + Get.lazyPut(() => QuizService()); + } Get.lazyPut(() => LibraryController(Get.find(), Get.find())); } } diff --git a/lib/feature/library/controller/detail_quiz_controller.dart b/lib/feature/library/controller/detail_quiz_controller.dart deleted file mode 100644 index e5b04fe..0000000 --- a/lib/feature/library/controller/detail_quiz_controller.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:get/get.dart'; -import 'package:quiz_app/app/routes/app_pages.dart'; -import 'package:quiz_app/data/models/quiz/library_quiz_model.dart'; - -class DetailQuizController extends GetxController { - late QuizData data; - @override - void onInit() { - loadData(); - super.onInit(); - } - - void loadData() { - data = Get.arguments as QuizData; - } - - void goToPlayPage() => Get.toNamed(AppRoutes.playQuizPage, arguments: data); -} diff --git a/lib/feature/library/view/detail_quix_view.dart b/lib/feature/library/view/detail_quix_view.dart deleted file mode 100644 index 5b0be23..0000000 --- a/lib/feature/library/view/detail_quix_view.dart +++ /dev/null @@ -1,176 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:quiz_app/app/const/colors/app_colors.dart'; -import 'package:quiz_app/component/global_button.dart'; -import 'package:quiz_app/data/models/quiz/question_listings_model.dart'; -import 'package:quiz_app/feature/library/controller/detail_quiz_controller.dart'; - -class DetailQuizView extends GetView { - const DetailQuizView({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.background, - appBar: AppBar( - backgroundColor: AppColors.background, - elevation: 0, - title: const Text( - 'Detail Quiz', - style: TextStyle( - color: AppColors.darkText, - fontWeight: FontWeight.bold, - ), - ), - centerTitle: true, - iconTheme: const IconThemeData(color: AppColors.darkText), - ), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(20), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header Section - Text( - controller.data.title, - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - color: AppColors.darkText, - ), - ), - const SizedBox(height: 8), - Text( - controller.data.description ?? "", - style: const TextStyle( - fontSize: 14, - color: AppColors.softGrayText, - ), - ), - const SizedBox(height: 16), - Row( - children: [ - const Icon(Icons.calendar_today_rounded, size: 16, color: AppColors.softGrayText), - const SizedBox(width: 6), - Text( - controller.data.date ?? "", - style: const TextStyle(fontSize: 12, color: AppColors.softGrayText), - ), - const SizedBox(width: 12), - const Icon(Icons.timer_rounded, size: 16, color: AppColors.softGrayText), - const SizedBox(width: 6), - Text( - '${controller.data.limitDuration ~/ 60} menit', - style: const TextStyle(fontSize: 12, color: AppColors.softGrayText), - ), - ], - ), - const SizedBox(height: 20), - const SizedBox(height: 20), - - GlobalButton(text: "Kerjakan", onPressed: controller.goToPlayPage), - const SizedBox(height: 20), - GlobalButton(text: "buat ruangan", onPressed: () {}), - - const SizedBox(height: 20), - const Divider(thickness: 1.2, color: AppColors.borderLight), - const SizedBox(height: 20), - // Soal Section - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: controller.data.questionListings.length, - itemBuilder: (context, index) { - final question = controller.data.questionListings[index]; - return _buildQuestionItem(question, index + 1); - }, - ), - ], - ), - ), - ), - ), - ); - } - - Widget _buildQuestionItem(QuestionListing question, int index) { - return Container( - width: double.infinity, - margin: const EdgeInsets.only(bottom: 16), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.borderLight), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 6, - offset: const Offset(2, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Soal $index', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - color: AppColors.darkText, - ), - ), - const SizedBox(height: 8), - Text( - _mapQuestionTypeToText(question.type), - style: const TextStyle( - fontSize: 12, - fontStyle: FontStyle.italic, - color: AppColors.softGrayText, - ), - ), - const SizedBox(height: 12), - Text( - question.question, - style: const TextStyle( - fontSize: 14, - color: AppColors.darkText, - ), - ), - // const SizedBox(height: 12), - // Text( - // 'Jawaban: ${question.targetAnswer}', - // style: const TextStyle( - // fontSize: 14, - // color: AppColors.softGrayText, - // ), - // ), - const SizedBox(height: 8), - Text( - 'Durasi: ${question.duration} detik', - style: const TextStyle( - fontSize: 12, - color: AppColors.softGrayText, - ), - ), - ], - ), - ); - } - - String _mapQuestionTypeToText(String? type) { - switch (type) { - case 'option': - return 'Pilihan Ganda'; - case 'fill_the_blank': - return 'Isian Kosong'; - case 'true_false': - return 'Benar / Salah'; - default: - return 'Tipe Tidak Diketahui'; - } - } -} diff --git a/lib/feature/library/view/library_view.dart b/lib/feature/library/view/library_view.dart index 7f574c6..58bcbcb 100644 --- a/lib/feature/library/view/library_view.dart +++ b/lib/feature/library/view/library_view.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/component/widget/loading_widget.dart'; import 'package:quiz_app/data/models/quiz/library_quiz_model.dart'; import 'package:quiz_app/feature/library/controller/library_controller.dart'; @@ -36,19 +37,7 @@ class LibraryView extends GetView { Expanded( child: Obx(() { if (controller.isLoading.value) { - return const Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CircularProgressIndicator(), - SizedBox(height: 12), - Text( - "Memuat data...", - style: TextStyle(color: Colors.grey, fontSize: 14), - ), - ], - ), - ); + return LoadingWidget(); } if (controller.quizs.isEmpty) { diff --git a/lib/feature/search/controller/search_controller.dart b/lib/feature/search/controller/search_controller.dart index da1e848..2a59484 100644 --- a/lib/feature/search/controller/search_controller.dart +++ b/lib/feature/search/controller/search_controller.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/data/models/base/base_model.dart'; import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; import 'package:quiz_app/data/services/quiz_service.dart'; @@ -44,6 +45,8 @@ class SearchQuizController extends GetxController { } } + void goToDetailPage(String quizId) => Get.toNamed(AppRoutes.detailQuizPage, arguments: quizId); + @override void onClose() { searchController.dispose(); diff --git a/lib/feature/search/view/search_view.dart b/lib/feature/search/view/search_view.dart index 248d695..14c5ee5 100644 --- a/lib/feature/search/view/search_view.dart +++ b/lib/feature/search/view/search_view.dart @@ -23,15 +23,15 @@ class SearchView extends GetView { if (isSearching) ...[ _buildCategoryFilter(), const SizedBox(height: 20), - ...controller.searchQData.map( - (e) => QuizContainerComponent(data: e), + (e) => QuizContainerComponent(data: e, onTap: controller.goToDetailPage), ) ] else ...[ Obx( () => RecomendationComponent( title: "Quiz Rekomendasi", datas: controller.recommendationQData.toList(), + itemOnTap: controller.goToDetailPage, ), ), const SizedBox(height: 30), @@ -39,6 +39,7 @@ class SearchView extends GetView { () => RecomendationComponent( title: "Quiz Populer", datas: controller.recommendationQData.toList(), + itemOnTap: controller.goToDetailPage, ), ), ], From 668c7eac27c2fd63b123bedcb45b43d175618f7c Mon Sep 17 00:00:00 2001 From: akhdanre Date: Fri, 2 May 2025 03:56:05 +0700 Subject: [PATCH 036/104] feat: listing quiz done --- lib/app/routes/app_pages.dart | 7 +++ lib/app/routes/app_routes.dart | 2 +- .../widget/recomendation_component.dart | 12 +++- lib/data/services/quiz_service.dart | 4 +- .../home/controller/home_controller.dart | 4 +- lib/feature/home/view/home_page.dart | 1 + .../binding/listing_quiz_binding.dart | 13 ++++ .../controller/listing_quiz_controller.dart | 63 +++++++++++++++++++ .../listing_quiz/view/listing_quiz_view.dart | 54 ++++++++++++++++ .../search/controller/search_controller.dart | 2 + lib/feature/search/view/search_view.dart | 2 + 11 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 lib/feature/listing_quiz/binding/listing_quiz_binding.dart create mode 100644 lib/feature/listing_quiz/controller/listing_quiz_controller.dart create mode 100644 lib/feature/listing_quiz/view/listing_quiz_view.dart diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index 7d3ada7..bb57760 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -6,6 +6,8 @@ import 'package:quiz_app/feature/home/view/home_page.dart'; import 'package:quiz_app/feature/detail_quiz/binding/detail_quiz_binding.dart'; import 'package:quiz_app/feature/library/binding/library_binding.dart'; import 'package:quiz_app/feature/detail_quiz/view/detail_quix_view.dart'; +import 'package:quiz_app/feature/listing_quiz/binding/listing_quiz_binding.dart'; +import 'package:quiz_app/feature/listing_quiz/view/listing_quiz_view.dart'; import 'package:quiz_app/feature/login/bindings/login_binding.dart'; import 'package:quiz_app/feature/login/view/login_page.dart'; import 'package:quiz_app/feature/navigation/bindings/navigation_binding.dart'; @@ -85,6 +87,11 @@ class AppPages { name: AppRoutes.resultQuizPage, page: () => QuizResultView(), binding: QuizResultBinding(), + ), + GetPage( + name: AppRoutes.listingQuizPage, + page: () => ListingsQuizView(), + binding: ListingQuizBinding(), ) ]; } diff --git a/lib/app/routes/app_routes.dart b/lib/app/routes/app_routes.dart index 8d4553a..12ae8a0 100644 --- a/lib/app/routes/app_routes.dart +++ b/lib/app/routes/app_routes.dart @@ -10,7 +10,7 @@ abstract class AppRoutes { static const quizCreatePage = "/quiz/creation"; static const quizPreviewPage = "/quiz/preview"; - + static const listingQuizPage = "/quiz/listing"; static const detailQuizPage = "/quiz/detail"; static const playQuizPage = "/quiz/play"; diff --git a/lib/component/widget/recomendation_component.dart b/lib/component/widget/recomendation_component.dart index 2cd43af..8d73c25 100644 --- a/lib/component/widget/recomendation_component.dart +++ b/lib/component/widget/recomendation_component.dart @@ -6,10 +6,13 @@ class RecomendationComponent extends StatelessWidget { final String title; final List datas; final Function(String) itemOnTap; + final Function() allOnTap; + const RecomendationComponent({ required this.title, required this.datas, required this.itemOnTap, + required this.allOnTap, super.key, }); @@ -60,9 +63,12 @@ class RecomendationComponent extends StatelessWidget { title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), - Text( - "Lihat semua", - style: TextStyle(fontSize: 14, color: Colors.blue.shade700), + GestureDetector( + onTap: allOnTap, + child: Text( + "Lihat semua", + style: TextStyle(fontSize: 14, color: Colors.blue.shade700), + ), ), ], ), diff --git a/lib/data/services/quiz_service.dart b/lib/data/services/quiz_service.dart index 94a1948..d41392b 100644 --- a/lib/data/services/quiz_service.dart +++ b/lib/data/services/quiz_service.dart @@ -51,9 +51,9 @@ class QuizService extends GetxService { } } - Future>?> recomendationQuiz() async { + Future>?> recomendationQuiz({int page = 1, int amount = 3}) async { try { - final response = await _dio.get(APIEndpoint.quizRecomendation); + final response = await _dio.get("${APIEndpoint.quizRecomendation}?page=$page&limit=$amount"); if (response.statusCode == 200) { final parsedResponse = BaseResponseModel>.fromJson( diff --git a/lib/feature/home/controller/home_controller.dart b/lib/feature/home/controller/home_controller.dart index bc8b970..c0b2d84 100644 --- a/lib/feature/home/controller/home_controller.dart +++ b/lib/feature/home/controller/home_controller.dart @@ -7,7 +7,7 @@ import 'package:quiz_app/data/services/quiz_service.dart'; class HomeController extends GetxController { final UserController _userController = Get.find(); - QuizService _quizService; + final QuizService _quizService; HomeController(this._quizService); Rx get userName => _userController.userName; @@ -31,4 +31,6 @@ class HomeController extends GetxController { } void onRecommendationTap(String quizId) => Get.toNamed(AppRoutes.detailQuizPage, arguments: quizId); + + void goToListingsQuizPage() => Get.toNamed(AppRoutes.listingQuizPage); } diff --git a/lib/feature/home/view/home_page.dart b/lib/feature/home/view/home_page.dart index 932453a..7f8b84a 100644 --- a/lib/feature/home/view/home_page.dart +++ b/lib/feature/home/view/home_page.dart @@ -48,6 +48,7 @@ class HomeView extends GetView { title: "Quiz Rekomendasi", datas: controller.data.toList(), itemOnTap: controller.onRecommendationTap, + allOnTap: controller.goToListingsQuizPage, ), ), ], diff --git a/lib/feature/listing_quiz/binding/listing_quiz_binding.dart b/lib/feature/listing_quiz/binding/listing_quiz_binding.dart new file mode 100644 index 0000000..ae61395 --- /dev/null +++ b/lib/feature/listing_quiz/binding/listing_quiz_binding.dart @@ -0,0 +1,13 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/data/services/quiz_service.dart'; +import 'package:quiz_app/feature/listing_quiz/controller/listing_quiz_controller.dart'; + +class ListingQuizBinding extends Bindings { + @override + void dependencies() { + if (!Get.isRegistered()) { + Get.lazyPut(() => QuizService()); + } + Get.lazyPut(() => ListingQuizController(Get.find())); + } +} diff --git a/lib/feature/listing_quiz/controller/listing_quiz_controller.dart b/lib/feature/listing_quiz/controller/listing_quiz_controller.dart new file mode 100644 index 0000000..6cf2b93 --- /dev/null +++ b/lib/feature/listing_quiz/controller/listing_quiz_controller.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/data/models/base/base_model.dart'; +import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; +import 'package:quiz_app/data/services/quiz_service.dart'; + +class ListingQuizController extends GetxController { + final QuizService _quizService; + + ListingQuizController(this._quizService); + + RxBool isLoading = false.obs; + RxBool isLoadingMore = false.obs; + RxList quizzes = [].obs; + + final ScrollController scrollController = ScrollController(); + + final int amountQuiz = 8; + int currentPage = 1; + bool hasMore = true; + + @override + void onInit() { + super.onInit(); + _getRecomendationQuiz(); + scrollController.addListener(_onScroll); + } + + void _onScroll() { + if (scrollController.position.pixels >= scrollController.position.maxScrollExtent - 200) { + if (!isLoadingMore.value && hasMore) { + loadMoreQuiz(); + } + } + } + + Future _getRecomendationQuiz() async { + isLoading.value = true; + currentPage = 1; + BaseResponseModel? response = await _quizService.recomendationQuiz(amount: amountQuiz); + if (response != null && response.data != null) { + final data = response.data as List; + quizzes.assignAll(data); + hasMore = data.length == amountQuiz; + } + isLoading.value = false; + } + + Future loadMoreQuiz() async { + isLoadingMore.value = true; + currentPage++; + BaseResponseModel? response = await _quizService.recomendationQuiz(page: currentPage, amount: amountQuiz); + if (response != null && response.data != null) { + final data = response.data as List; + quizzes.addAll(data); + hasMore = data.length == amountQuiz; + } + isLoadingMore.value = false; + } + + void goToDetailQuiz(String quizId) => Get.toNamed(AppRoutes.detailQuizPage, arguments: quizId); +} diff --git a/lib/feature/listing_quiz/view/listing_quiz_view.dart b/lib/feature/listing_quiz/view/listing_quiz_view.dart new file mode 100644 index 0000000..af386f3 --- /dev/null +++ b/lib/feature/listing_quiz/view/listing_quiz_view.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/component/quiz_container_component.dart'; +import 'package:quiz_app/feature/listing_quiz/controller/listing_quiz_controller.dart'; + +class ListingsQuizView extends GetView { + const ListingsQuizView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + centerTitle: true, + title: const Text( + 'Daftar Kuis', + ), + ), + body: Padding( + padding: const EdgeInsets.all(20.0), + child: Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + + if (controller.quizzes.isEmpty) { + return const Center(child: Text('Tidak ada kuis tersedia.')); + } + + return ListView.builder( + controller: controller.scrollController, + itemCount: controller.quizzes.length + 1, // +1 untuk indikator loading + itemBuilder: (context, index) { + if (index == controller.quizzes.length) { + return Obx(() => controller.isLoadingMore.value + ? const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ) + : const SizedBox()); + } + + final quiz = controller.quizzes[index]; + return QuizContainerComponent( + data: quiz, + onTap: controller.goToDetailQuiz, + ); + }, + ); + })), + ); + } +} diff --git a/lib/feature/search/controller/search_controller.dart b/lib/feature/search/controller/search_controller.dart index 2a59484..f8c932c 100644 --- a/lib/feature/search/controller/search_controller.dart +++ b/lib/feature/search/controller/search_controller.dart @@ -47,6 +47,8 @@ class SearchQuizController extends GetxController { void goToDetailPage(String quizId) => Get.toNamed(AppRoutes.detailQuizPage, arguments: quizId); + void goToListingsQuizPage() => Get.toNamed(AppRoutes.listingQuizPage); + @override void onClose() { searchController.dispose(); diff --git a/lib/feature/search/view/search_view.dart b/lib/feature/search/view/search_view.dart index 14c5ee5..a188618 100644 --- a/lib/feature/search/view/search_view.dart +++ b/lib/feature/search/view/search_view.dart @@ -32,6 +32,7 @@ class SearchView extends GetView { title: "Quiz Rekomendasi", datas: controller.recommendationQData.toList(), itemOnTap: controller.goToDetailPage, + allOnTap: controller.goToListingsQuizPage, ), ), const SizedBox(height: 30), @@ -40,6 +41,7 @@ class SearchView extends GetView { title: "Quiz Populer", datas: controller.recommendationQData.toList(), itemOnTap: controller.goToDetailPage, + allOnTap: controller.goToListingsQuizPage, ), ), ], From 9df43d451e283ec0918be08a9c31be4a025c9049 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Fri, 2 May 2025 04:13:58 +0700 Subject: [PATCH 037/104] fix: info not showing --- lib/component/quiz_container_component.dart | 124 ++++++++++--------- lib/data/models/quiz/quiz_listing_model.dart | 8 ++ 2 files changed, 74 insertions(+), 58 deletions(-) diff --git a/lib/component/quiz_container_component.dart b/lib/component/quiz_container_component.dart index 137ad6c..97bd6f2 100644 --- a/lib/component/quiz_container_component.dart +++ b/lib/component/quiz_container_component.dart @@ -4,8 +4,13 @@ import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; class QuizContainerComponent extends StatelessWidget { final QuizListingModel data; - final Function(String) onTap; - const QuizContainerComponent({required this.data, required this.onTap, super.key}); + final void Function(String quizId) onTap; + + const QuizContainerComponent({ + required this.data, + required this.onTap, + super.key, + }); @override Widget build(BuildContext context) { @@ -16,75 +21,78 @@ class QuizContainerComponent extends StatelessWidget { decoration: BoxDecoration( color: AppColors.background, borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Color(0xFFE1E4E8), - ), + border: Border.all(color: const Color(0xFFE1E4E8)), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.03), blurRadius: 8, - offset: Offset(0, 2), - ) + offset: const Offset(0, 2), + ), ], ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 50, - height: 50, - decoration: BoxDecoration( - color: Color(0xFF0052CC), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon(Icons.school, color: Colors.white, size: 28), - ), + _buildIconBox(), const SizedBox(width: 12), - // Quiz Info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - data.title, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Color(0xFF172B4D), - ), - ), - const SizedBox(height: 4), - Text( - "created by ${data.authorName}", - style: TextStyle( - fontSize: 12, - color: Color(0xFF6B778C), - ), - ), - const SizedBox(height: 8), - Row( - children: const [ - Icon(Icons.format_list_bulleted, size: 14, color: Color(0xFF6B778C)), - SizedBox(width: 4), - Text( - "50 Quizzes", - style: TextStyle(fontSize: 12, color: Color(0xFF6B778C)), - ), - SizedBox(width: 12), - Icon(Icons.access_time, size: 14, color: Color(0xFF6B778C)), - SizedBox(width: 4), - Text( - "1 hr duration", - style: TextStyle(fontSize: 12, color: Color(0xFF6B778C)), - ), - ], - ) - ], - ), - ) + Expanded(child: _buildQuizInfo()), ], ), ), ); } + + Widget _buildIconBox() { + return Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: const Color(0xFF0052CC), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.school, color: Colors.white, size: 28), + ); + } + + Widget _buildQuizInfo() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data.title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF172B4D), + ), + ), + const SizedBox(height: 4), + Text( + 'Created by ${data.authorName}', + style: const TextStyle( + fontSize: 12, + color: Color(0xFF6B778C), + ), + ), + const SizedBox(height: 8), + Row( + children: [ + const Icon(Icons.format_list_bulleted, size: 14, color: Color(0xFF6B778C)), + const SizedBox(width: 4), + Text( + '${data.totalQuiz} Quizzes', + style: const TextStyle(fontSize: 12, color: Color(0xFF6B778C)), + ), + const SizedBox(width: 12), + const Icon(Icons.access_time, size: 14, color: Color(0xFF6B778C)), + const SizedBox(width: 4), + Text( + '${data.duration} menit', + style: const TextStyle(fontSize: 12, color: Color(0xFF6B778C)), + ), + ], + ), + ], + ); + } } diff --git a/lib/data/models/quiz/quiz_listing_model.dart b/lib/data/models/quiz/quiz_listing_model.dart index 7f9bfc6..3df0073 100644 --- a/lib/data/models/quiz/quiz_listing_model.dart +++ b/lib/data/models/quiz/quiz_listing_model.dart @@ -5,6 +5,8 @@ class QuizListingModel { final String title; final String description; final String date; + final int totalQuiz; + final int duration; QuizListingModel({ required this.quizId, @@ -13,6 +15,8 @@ class QuizListingModel { required this.title, required this.description, required this.date, + required this.duration, + required this.totalQuiz, }); factory QuizListingModel.fromJson(Map json) { @@ -23,6 +27,8 @@ class QuizListingModel { title: json['title'] as String, description: json['description'] as String, date: json['date'] as String, + duration: json['duration'] as int, + totalQuiz: json["total_quiz"] as int, ); } @@ -34,6 +40,8 @@ class QuizListingModel { 'title': title, 'description': description, 'date': date, + 'duration': duration, + "total_quiz": totalQuiz }; } } From 6bf48df48ab2c3652ea69a7a757d0e4cac7a0dc2 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 4 May 2025 01:15:56 +0700 Subject: [PATCH 038/104] fix: listing request model and logic --- lib/data/models/quiz/library_quiz_model.dart | 18 ++++- .../quiz/question/base_qustion_model.dart | 32 ++++++++ .../fill_in_the_blank_question_model.dart | 31 +++++++ .../quiz/question/option_question_model.dart | 34 ++++++++ .../question/true_false_question_model.dart | 31 +++++++ .../models/quiz/question_listings_model.dart | 2 +- lib/data/services/quiz_service.dart | 25 +++--- .../controller/detail_quiz_controller.dart | 8 +- .../detail_quiz/view/detail_quix_view.dart | 4 +- .../controller/history_controller.dart | 3 + lib/feature/history/view/history_view.dart | 81 ++++++++++--------- .../controller/library_controller.dart | 13 +-- lib/feature/library/view/library_view.dart | 10 +-- .../controller/quiz_play_controller.dart | 37 ++++++--- .../quiz_play/view/quiz_play_view.dart | 74 ++++++++--------- .../controller/quiz_result_controller.dart | 2 +- 16 files changed, 284 insertions(+), 121 deletions(-) create mode 100644 lib/data/models/quiz/question/base_qustion_model.dart create mode 100644 lib/data/models/quiz/question/fill_in_the_blank_question_model.dart create mode 100644 lib/data/models/quiz/question/option_question_model.dart create mode 100644 lib/data/models/quiz/question/true_false_question_model.dart diff --git a/lib/data/models/quiz/library_quiz_model.dart b/lib/data/models/quiz/library_quiz_model.dart index e60214c..cc8d7a8 100644 --- a/lib/data/models/quiz/library_quiz_model.dart +++ b/lib/data/models/quiz/library_quiz_model.dart @@ -1,21 +1,27 @@ -import 'package:quiz_app/data/models/quiz/question_listings_model.dart'; +import 'package:quiz_app/data/models/quiz/question/base_qustion_model.dart'; class QuizData { final String authorId; + final String subjectId; + final String subjectName; final String title; final String? description; final bool isPublic; final String? date; + final String? time; final int totalQuiz; final int limitDuration; - final List questionListings; + final List questionListings; QuizData({ required this.authorId, + required this.subjectId, + required this.subjectName, required this.title, this.description, required this.isPublic, this.date, + this.time, required this.totalQuiz, required this.limitDuration, required this.questionListings, @@ -24,23 +30,29 @@ class QuizData { factory QuizData.fromJson(Map json) { return QuizData( authorId: json['author_id'], + subjectId: json['subject_id'], + subjectName: json['subject_alias'], title: json['title'], description: json['description'], isPublic: json['is_public'], date: json['date'], + time: json['time'], totalQuiz: json['total_quiz'], limitDuration: json['limit_duration'], - questionListings: (json['question_listings'] as List).map((e) => QuestionListing.fromJson(e)).toList(), + questionListings: (json['question_listings'] as List).map((e) => BaseQuestionModel.fromJson(e as Map)).toList(), ); } Map toJson() { return { 'author_id': authorId, + 'subject_id': subjectId, + 'subject_alias': subjectName, 'title': title, 'description': description, 'is_public': isPublic, 'date': date, + 'time': time, 'total_quiz': totalQuiz, 'limit_duration': limitDuration, 'question_listings': questionListings.map((e) => e.toJson()).toList(), diff --git a/lib/data/models/quiz/question/base_qustion_model.dart b/lib/data/models/quiz/question/base_qustion_model.dart new file mode 100644 index 0000000..31843b2 --- /dev/null +++ b/lib/data/models/quiz/question/base_qustion_model.dart @@ -0,0 +1,32 @@ +import 'package:quiz_app/data/models/quiz/question/fill_in_the_blank_question_model.dart'; +import 'package:quiz_app/data/models/quiz/question/option_question_model.dart'; +import 'package:quiz_app/data/models/quiz/question/true_false_question_model.dart'; + +abstract class BaseQuestionModel { + final int index; + final String question; + final int duration; + final String type; + + BaseQuestionModel({ + required this.index, + required this.question, + required this.duration, + required this.type, + }); + + factory BaseQuestionModel.fromJson(Map json) { + switch (json['type']) { + case 'fill_the_blank': + return FillInTheBlankQuestion.fromJson(json); + case 'true_false': + return TrueFalseQuestion.fromJson(json); + case 'option': + return OptionQuestion.fromJson(json); + default: + throw Exception('Unsupported question type: ${json['type']}'); + } + } + + Map toJson(); +} diff --git a/lib/data/models/quiz/question/fill_in_the_blank_question_model.dart b/lib/data/models/quiz/question/fill_in_the_blank_question_model.dart new file mode 100644 index 0000000..63aa35c --- /dev/null +++ b/lib/data/models/quiz/question/fill_in_the_blank_question_model.dart @@ -0,0 +1,31 @@ +import 'package:quiz_app/data/models/quiz/question/base_qustion_model.dart'; + +class FillInTheBlankQuestion extends BaseQuestionModel { + final String targetAnswer; + + FillInTheBlankQuestion({ + required int index, + required String question, + required int duration, + required this.targetAnswer, + }) : super(index: index, question: question, duration: duration, type: 'fill_the_blank'); + + factory FillInTheBlankQuestion.fromJson(Map json) { + return FillInTheBlankQuestion( + index: json['index'], + question: json['question'], + duration: json['duration'], + targetAnswer: json['target_answer'], + ); + } + + @override + Map toJson() => { + 'index': index, + 'question': question, + 'duration': duration, + 'type': type, + 'target_answer': targetAnswer, + 'options': null, + }; +} diff --git a/lib/data/models/quiz/question/option_question_model.dart b/lib/data/models/quiz/question/option_question_model.dart new file mode 100644 index 0000000..e04355a --- /dev/null +++ b/lib/data/models/quiz/question/option_question_model.dart @@ -0,0 +1,34 @@ +import 'package:quiz_app/data/models/quiz/question/base_qustion_model.dart'; + +class OptionQuestion extends BaseQuestionModel { + final int targetAnswer; + final List options; + + OptionQuestion({ + required int index, + required String question, + required int duration, + required this.targetAnswer, + required this.options, + }) : super(index: index, question: question, duration: duration, type: 'option'); + + factory OptionQuestion.fromJson(Map json) { + return OptionQuestion( + index: json['index'], + question: json['question'], + duration: json['duration'], + targetAnswer: json['target_answer'], + options: List.from(json['options']), + ); + } + + @override + Map toJson() => { + 'index': index, + 'question': question, + 'duration': duration, + 'type': type, + 'target_answer': targetAnswer, + 'options': options, + }; +} diff --git a/lib/data/models/quiz/question/true_false_question_model.dart b/lib/data/models/quiz/question/true_false_question_model.dart new file mode 100644 index 0000000..e4f0768 --- /dev/null +++ b/lib/data/models/quiz/question/true_false_question_model.dart @@ -0,0 +1,31 @@ +import 'package:quiz_app/data/models/quiz/question/base_qustion_model.dart'; + +class TrueFalseQuestion extends BaseQuestionModel { + final bool targetAnswer; + + TrueFalseQuestion({ + required int index, + required String question, + required int duration, + required this.targetAnswer, + }) : super(index: index, question: question, duration: duration, type: 'true_false'); + + factory TrueFalseQuestion.fromJson(Map json) { + return TrueFalseQuestion( + index: json['index'], + question: json['question'], + duration: json['duration'], + targetAnswer: json['target_answer'], + ); + } + + @override + Map toJson() => { + 'index': index, + 'question': question, + 'duration': duration, + 'type': type, + 'target_answer': targetAnswer, + 'options': null, + }; +} diff --git a/lib/data/models/quiz/question_listings_model.dart b/lib/data/models/quiz/question_listings_model.dart index 2e74dde..32ab571 100644 --- a/lib/data/models/quiz/question_listings_model.dart +++ b/lib/data/models/quiz/question_listings_model.dart @@ -1,7 +1,7 @@ class QuestionListing { final int index; final String question; - final String targetAnswer; + final dynamic targetAnswer; final int duration; final String type; final List? options; diff --git a/lib/data/services/quiz_service.dart b/lib/data/services/quiz_service.dart index d41392b..66e590e 100644 --- a/lib/data/services/quiz_service.dart +++ b/lib/data/services/quiz_service.dart @@ -35,19 +35,22 @@ class QuizService extends GetxService { } } - Future> userQuiz(String userId, int page) async { + Future>?> userQuiz(String userId, int page) async { try { final response = await _dio.get("${APIEndpoint.userQuiz}/$userId?page=$page"); - - final parsedResponse = BaseResponseModel>.fromJson( - response.data, - (data) => (data as List).map((e) => QuizData.fromJson(e as Map)).toList(), - ); - - return parsedResponse.data ?? []; + if (response.statusCode == 200) { + final parsedResponse = BaseResponseModel>.fromJson( + response.data, + (data) => (data as List).map((e) => QuizListingModel.fromJson(e as Map)).toList(), + ); + return parsedResponse; + } else { + logC.e("Failed to fetch recommendation quizzes. Status: ${response.statusCode}"); + return null; + } } catch (e) { logC.e("Error fetching user quizzes: $e"); - return []; + return null; } } @@ -105,8 +108,8 @@ class QuizService extends GetxService { logC.e("Failed to fetch quiz by id. Status: ${response.statusCode}"); return null; } - } catch (e) { - logC.e("Error fetching quiz by id $e"); + } catch (e, stacktrace) { + logC.e("Error fetching quiz by id $e", stackTrace: stacktrace); return null; } } diff --git a/lib/feature/detail_quiz/controller/detail_quiz_controller.dart b/lib/feature/detail_quiz/controller/detail_quiz_controller.dart index a4ecddf..e089de2 100644 --- a/lib/feature/detail_quiz/controller/detail_quiz_controller.dart +++ b/lib/feature/detail_quiz/controller/detail_quiz_controller.dart @@ -20,12 +20,8 @@ class DetailQuizController extends GetxController { } void loadData() async { - final args = Get.arguments; - if (args is QuizData) { - data = args; - } else { - getQuizData(args); - } + final quizId = Get.arguments as String; + getQuizData(quizId); } void getQuizData(String quizId) async { diff --git a/lib/feature/detail_quiz/view/detail_quix_view.dart b/lib/feature/detail_quiz/view/detail_quix_view.dart index a178585..6836916 100644 --- a/lib/feature/detail_quiz/view/detail_quix_view.dart +++ b/lib/feature/detail_quiz/view/detail_quix_view.dart @@ -3,7 +3,7 @@ import 'package:get/get.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; import 'package:quiz_app/component/global_button.dart'; import 'package:quiz_app/component/widget/loading_widget.dart'; -import 'package:quiz_app/data/models/quiz/question_listings_model.dart'; +import 'package:quiz_app/data/models/quiz/question/base_qustion_model.dart'; import 'package:quiz_app/feature/detail_quiz/controller/detail_quiz_controller.dart'; class DetailQuizView extends GetView { @@ -100,7 +100,7 @@ class DetailQuizView extends GetView { ); } - Widget _buildQuestionItem(QuestionListing question, int index) { + Widget _buildQuestionItem(BaseQuestionModel question, int index) { return Container( width: double.infinity, margin: const EdgeInsets.only(bottom: 16), diff --git a/lib/feature/history/controller/history_controller.dart b/lib/feature/history/controller/history_controller.dart index 44a2f5d..786e262 100644 --- a/lib/feature/history/controller/history_controller.dart +++ b/lib/feature/history/controller/history_controller.dart @@ -9,6 +9,8 @@ class HistoryController extends GetxController { HistoryController(this._historyService, this._userController); + RxBool isLoading = true.obs; + final historyList = [].obs; @override @@ -19,5 +21,6 @@ class HistoryController extends GetxController { void loadDummyHistory() async { historyList.value = await _historyService.getHistory(_userController.userData!.id) ?? []; + isLoading.value = false; } } diff --git a/lib/feature/history/view/history_view.dart b/lib/feature/history/view/history_view.dart index 2b9a09e..e8ab89f 100644 --- a/lib/feature/history/view/history_view.dart +++ b/lib/feature/history/view/history_view.dart @@ -13,45 +13,54 @@ class HistoryView extends GetView { body: SafeArea( child: Padding( padding: const EdgeInsets.all(16), - child: Obx(() { - final historyList = controller.historyList; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - "Riwayat Kuis", - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Riwayat Kuis", + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, ), - const SizedBox(height: 8), - const Text( - "Lihat kembali hasil kuis yang telah kamu kerjakan", - style: TextStyle( - fontSize: 14, - color: Colors.grey, - ), + ), + const SizedBox(height: 8), + const Text( + "Lihat kembali hasil kuis yang telah kamu kerjakan", + style: TextStyle( + fontSize: 14, + color: Colors.grey, ), - const SizedBox(height: 20), - if (historyList.isEmpty) - const Expanded( - child: Center(child: LoadingWidget()), - ) - else - Expanded( - child: ListView.builder( - itemCount: historyList.length, - itemBuilder: (context, index) { - final item = historyList[index]; - return _buildHistoryCard(item); - }, + ), + const SizedBox(height: 20), + Obx(() { + if (controller.isLoading.value) { + return Expanded( + child: Center( + child: LoadingWidget(), ), - ) - ], - ); - }), + ); + } + + final historyList = controller.historyList; + + if (historyList.isEmpty) { + return const Expanded( + child: Center(child: Text("you still doesnt have quiz history")), + ); + } + + return Expanded( + child: ListView.builder( + itemCount: historyList.length, + itemBuilder: (context, index) { + final item = historyList[index]; + return _buildHistoryCard(item); + }, + ), + ); + }), + ], + ), ), ), ); diff --git a/lib/feature/library/controller/library_controller.dart b/lib/feature/library/controller/library_controller.dart index 93d0da5..37a5425 100644 --- a/lib/feature/library/controller/library_controller.dart +++ b/lib/feature/library/controller/library_controller.dart @@ -1,11 +1,12 @@ import 'package:get/get.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; -import 'package:quiz_app/data/models/quiz/library_quiz_model.dart'; +import 'package:quiz_app/data/models/base/base_model.dart'; +import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; import 'package:quiz_app/data/services/quiz_service.dart'; class LibraryController extends GetxController { - RxList quizs = [].obs; + RxList quizs = [].obs; RxBool isLoading = true.obs; RxString emptyMessage = "".obs; @@ -24,11 +25,11 @@ class LibraryController extends GetxController { void loadUserQuiz() async { try { isLoading.value = true; - List data = await _quizService.userQuiz(_userController.userData!.id, currentPage); - if (data.isEmpty) { + BaseResponseModel>? response = await _quizService.userQuiz(_userController.userData!.id, currentPage); + if (response == null) { emptyMessage.value = "Kamu belum membuat soal."; } else { - quizs.addAll(data); + quizs.assignAll(response.data!); } } catch (e) { emptyMessage.value = "Terjadi kesalahan saat memuat data."; @@ -38,7 +39,7 @@ class LibraryController extends GetxController { } void goToDetail(int index) { - Get.toNamed(AppRoutes.detailQuizPage, arguments: quizs[index]); + Get.toNamed(AppRoutes.detailQuizPage, arguments: quizs[index].quizId); } String formatDuration(int seconds) { diff --git a/lib/feature/library/view/library_view.dart b/lib/feature/library/view/library_view.dart index 58bcbcb..916173f 100644 --- a/lib/feature/library/view/library_view.dart +++ b/lib/feature/library/view/library_view.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/component/widget/loading_widget.dart'; -import 'package:quiz_app/data/models/quiz/library_quiz_model.dart'; +import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; import 'package:quiz_app/feature/library/controller/library_controller.dart'; class LibraryView extends GetView { @@ -65,7 +65,7 @@ class LibraryView extends GetView { ); } - Widget _buildQuizCard(QuizData quiz) { + Widget _buildQuizCard(QuizListingModel quiz) { return Container( margin: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.all(16), @@ -108,7 +108,7 @@ class LibraryView extends GetView { ), const SizedBox(height: 4), Text( - quiz.description ?? "", + quiz.description, style: const TextStyle( color: Colors.grey, fontSize: 12, @@ -122,7 +122,7 @@ class LibraryView extends GetView { const Icon(Icons.calendar_today_rounded, size: 14, color: Colors.grey), const SizedBox(width: 4), Text( - controller.formatDate(quiz.date ?? ""), + controller.formatDate(quiz.date), style: const TextStyle(fontSize: 12, color: Colors.grey), ), const SizedBox(width: 12), @@ -136,7 +136,7 @@ class LibraryView extends GetView { const Icon(Icons.access_time, size: 14, color: Colors.grey), const SizedBox(width: 4), Text( - controller.formatDuration(quiz.limitDuration), + controller.formatDuration(quiz.duration), style: const TextStyle(fontSize: 12, color: Colors.grey), ), ], diff --git a/lib/feature/quiz_play/controller/quiz_play_controller.dart b/lib/feature/quiz_play/controller/quiz_play_controller.dart index 9e28bf6..ed5bd01 100644 --- a/lib/feature/quiz_play/controller/quiz_play_controller.dart +++ b/lib/feature/quiz_play/controller/quiz_play_controller.dart @@ -5,7 +5,7 @@ import 'package:get/get.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/component/notification/pop_up_confirmation.dart'; import 'package:quiz_app/data/models/quiz/library_quiz_model.dart'; -import 'package:quiz_app/data/models/quiz/question_listings_model.dart'; +import 'package:quiz_app/data/models/quiz/question/base_qustion_model.dart'; class QuizPlayController extends GetxController { late final QuizData quizData; @@ -28,7 +28,7 @@ class QuizPlayController extends GetxController { Timer? _timer; - QuestionListing get currentQuestion => quizData.questionListings[currentIndex.value]; + BaseQuestionModel get currentQuestion => quizData.questionListings[currentIndex.value]; @override void onInit() { @@ -99,14 +99,14 @@ class QuizPlayController extends GetxController { userAnswer = selectedAnswer.value.trim(); break; } - answeredQuestions.add(AnsweredQuestion( - index: currentIndex.value, - questionIndex: question.index, - selectedAnswer: userAnswer, - correctAnswer: question.targetAnswer.trim(), - isCorrect: userAnswer.toLowerCase() == question.targetAnswer.trim().toLowerCase(), - duration: currentQuestion.duration - timeLeft.value, - )); + // answeredQuestions.add(AnsweredQuestion( + // index: currentIndex.value, + // questionIndex: question.index, + // selectedAnswer: userAnswer, + // correctAnswer: question., + // isCorrect: userAnswer.toLowerCase() == question.targetAnswer.trim().toLowerCase(), + // duration: currentQuestion.duration - timeLeft.value, + // )); } void nextQuestion() { @@ -136,7 +136,7 @@ class QuizPlayController extends GetxController { void _finishQuiz() async { _timer?.cancel(); - + AppDialog.showMessage(Get.context!, "Yeay semua soal selesai"); await Future.delayed(Duration(seconds: 2)); Get.offAllNamed( @@ -156,8 +156,8 @@ class QuizPlayController extends GetxController { class AnsweredQuestion { final int index; final int questionIndex; - final String selectedAnswer; - final String correctAnswer; + final dynamic selectedAnswer; + final dynamic correctAnswer; final bool isCorrect; final int duration; @@ -170,6 +170,17 @@ class AnsweredQuestion { required this.duration, }); + factory AnsweredQuestion.fromJson(Map json) { + return AnsweredQuestion( + index: json['index'], + questionIndex: json['question_index'], + selectedAnswer: json['selectedAnswer'], + correctAnswer: json['correctAnswer'], + isCorrect: json['isCorrect'], + duration: json['duration'], + ); + } + Map toJson() => { 'index': index, 'question_index': questionIndex, diff --git a/lib/feature/quiz_play/view/quiz_play_view.dart b/lib/feature/quiz_play/view/quiz_play_view.dart index dc6ac95..e06e727 100644 --- a/lib/feature/quiz_play/view/quiz_play_view.dart +++ b/lib/feature/quiz_play/view/quiz_play_view.dart @@ -35,7 +35,7 @@ class QuizPlayView extends GetView { const SizedBox(height: 12), _buildQuestionText(), const SizedBox(height: 30), - _buildAnswerSection(), + // _buildAnswerSection(), const Spacer(), _buildNextButton(), ], @@ -100,44 +100,44 @@ class QuizPlayView extends GetView { ); } - Widget _buildAnswerSection() { - final question = controller.currentQuestion; + // Widget _buildAnswerSection() { + // final question = controller.currentQuestion; - if (question.type == 'option' && question.options != null) { - return Column( - children: List.generate(question.options!.length, (index) { - final option = question.options![index]; - final isSelected = controller.idxOptionSelected.value == index; + // if (question.type == 'option' && question.options != null) { + // return Column( + // children: List.generate(question.options!.length, (index) { + // final option = question.options![index]; + // final isSelected = controller.idxOptionSelected.value == index; - return Container( - margin: const EdgeInsets.only(bottom: 12), - width: double.infinity, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: isSelected ? AppColors.primaryBlue : Colors.white, - foregroundColor: isSelected ? Colors.white : Colors.black, - side: const BorderSide(color: Colors.grey), - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - onPressed: () => controller.selectAnswerOption(index), - child: Text(option), - ), - ); - }), - ); - } else if (question.type == 'true_false') { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _buildTrueFalseButton('Ya', true, controller.choosenAnswerTOF), - _buildTrueFalseButton('Tidak', false, controller.choosenAnswerTOF), - ], - ); - } else { - return GlobalTextField(controller: controller.answerTextController); - } - } + // return Container( + // margin: const EdgeInsets.only(bottom: 12), + // width: double.infinity, + // child: ElevatedButton( + // style: ElevatedButton.styleFrom( + // backgroundColor: isSelected ? AppColors.primaryBlue : Colors.white, + // foregroundColor: isSelected ? Colors.white : Colors.black, + // side: const BorderSide(color: Colors.grey), + // padding: const EdgeInsets.symmetric(vertical: 14), + // shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + // ), + // onPressed: () => controller.selectAnswerOption(index), + // child: Text(option), + // ), + // ); + // }), + // ); + // } else if (question.type == 'true_false') { + // return Row( + // mainAxisAlignment: MainAxisAlignment.spaceEvenly, + // children: [ + // _buildTrueFalseButton('Ya', true, controller.choosenAnswerTOF), + // _buildTrueFalseButton('Tidak', false, controller.choosenAnswerTOF), + // ], + // ); + // } else { + // return GlobalTextField(controller: controller.answerTextController); + // } + // } Widget _buildTrueFalseButton(String label, bool value, RxInt choosenAnswer) { return Obx(() { diff --git a/lib/feature/quiz_result/controller/quiz_result_controller.dart b/lib/feature/quiz_result/controller/quiz_result_controller.dart index ee36b26..5bf445a 100644 --- a/lib/feature/quiz_result/controller/quiz_result_controller.dart +++ b/lib/feature/quiz_result/controller/quiz_result_controller.dart @@ -26,7 +26,7 @@ class QuizResultController extends GetxController { final args = Get.arguments as List; question = args[0] as QuizData; - questions = question.questionListings; + // questions = question.questionListings; answers = args[1] as List; totalQuestions.value = questions.length; } From 8f7ca1b457da55cfdf25386f9b97a4720f5ebc87 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 4 May 2025 02:39:26 +0700 Subject: [PATCH 039/104] feat: detail histroy done --- lib/app/routes/app_pages.dart | 7 + lib/app/routes/app_routes.dart | 2 + lib/component/global_button.dart | 2 +- lib/component/quiz_container_component.dart | 2 +- .../widget/question_container_widget.dart | 4 +- lib/core/endpoint/api_endpoint.dart | 1 + .../models/history/detail_quiz_history.dart | 75 ++++++++ .../fill_in_the_blank_question_model.dart | 8 +- .../quiz/question/option_question_model.dart | 8 +- .../question/true_false_question_model.dart | 8 +- lib/data/services/history_service.dart | 16 ++ .../detail_quiz/view/detail_quix_view.dart | 2 +- .../binding/detail_history_binding.dart | 11 ++ .../controller/detail_history_controller.dart | 30 ++++ .../controller/history_controller.dart | 7 +- .../view/component/quiz_item_component.dart | 160 +++++++++++++++++ .../history/view/detail_history_view.dart | 163 ++++++++++++++++++ lib/feature/history/view/history_view.dart | 123 ++++++------- .../home/view/component/button_option.dart | 2 +- .../home/view/component/search_component.dart | 2 +- lib/feature/library/view/library_view.dart | 2 +- .../controller/profile_controller.dart | 5 +- .../quiz_play/view/quiz_play_view.dart | 2 - pubspec.lock | 8 + pubspec.yaml | 1 + 25 files changed, 565 insertions(+), 86 deletions(-) create mode 100644 lib/data/models/history/detail_quiz_history.dart create mode 100644 lib/feature/history/binding/detail_history_binding.dart create mode 100644 lib/feature/history/controller/detail_history_controller.dart create mode 100644 lib/feature/history/view/component/quiz_item_component.dart create mode 100644 lib/feature/history/view/detail_history_view.dart diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index bb57760..18eb3b1 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -1,6 +1,8 @@ import 'package:get/get_navigation/src/routes/get_route.dart'; import 'package:quiz_app/app/middleware/auth_middleware.dart'; +import 'package:quiz_app/feature/history/binding/detail_history_binding.dart'; import 'package:quiz_app/feature/history/binding/history_binding.dart'; +import 'package:quiz_app/feature/history/view/detail_history_view.dart'; import 'package:quiz_app/feature/home/binding/home_binding.dart'; import 'package:quiz_app/feature/home/view/home_page.dart'; import 'package:quiz_app/feature/detail_quiz/binding/detail_quiz_binding.dart'; @@ -92,6 +94,11 @@ class AppPages { name: AppRoutes.listingQuizPage, page: () => ListingsQuizView(), binding: ListingQuizBinding(), + ), + GetPage( + name: AppRoutes.detailHistoryPage, + page: () => DetailHistoryView(), + binding: DetailHistoryBinding(), ) ]; } diff --git a/lib/app/routes/app_routes.dart b/lib/app/routes/app_routes.dart index 12ae8a0..24a779d 100644 --- a/lib/app/routes/app_routes.dart +++ b/lib/app/routes/app_routes.dart @@ -15,4 +15,6 @@ abstract class AppRoutes { static const playQuizPage = "/quiz/play"; static const resultQuizPage = "/quiz/result"; + + static const detailHistoryPage = "/history/detail"; } diff --git a/lib/component/global_button.dart b/lib/component/global_button.dart index 0f03d20..eb54c81 100644 --- a/lib/component/global_button.dart +++ b/lib/component/global_button.dart @@ -45,7 +45,7 @@ class GlobalButton extends StatelessWidget { backgroundColor: backgroundColor, foregroundColor: foregroundColor, elevation: isDisabled ? 0 : 4, - shadowColor: !isDisabled ? backgroundColor.withOpacity(0.3) : Colors.transparent, + shadowColor: !isDisabled ? backgroundColor.withValues(alpha: 0.3) : Colors.transparent, padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), diff --git a/lib/component/quiz_container_component.dart b/lib/component/quiz_container_component.dart index 97bd6f2..4e8c644 100644 --- a/lib/component/quiz_container_component.dart +++ b/lib/component/quiz_container_component.dart @@ -24,7 +24,7 @@ class QuizContainerComponent extends StatelessWidget { border: Border.all(color: const Color(0xFFE1E4E8)), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.03), + color: Colors.black.withValues(alpha: 0.03), blurRadius: 8, offset: const Offset(0, 2), ), diff --git a/lib/component/widget/question_container_widget.dart b/lib/component/widget/question_container_widget.dart index c53ee95..448652b 100644 --- a/lib/component/widget/question_container_widget.dart +++ b/lib/component/widget/question_container_widget.dart @@ -97,7 +97,7 @@ class QuestionContainerWidget extends StatelessWidget { margin: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), decoration: BoxDecoration( - color: isCorrect ? AppColors.primaryBlue.withOpacity(0.1) : Colors.transparent, + color: isCorrect ? AppColors.primaryBlue.withValues(alpha: 0.1) : Colors.transparent, borderRadius: BorderRadius.circular(8), ), child: Row( @@ -236,7 +236,7 @@ class QuestionContainerWidget extends StatelessWidget { border: Border.all(color: AppColors.borderLight), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.05), + color: Colors.black.withValues(alpha: 0.05), blurRadius: 6, offset: const Offset(2, 2), ), diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart index e1c5aec..b76756a 100644 --- a/lib/core/endpoint/api_endpoint.dart +++ b/lib/core/endpoint/api_endpoint.dart @@ -12,4 +12,5 @@ class APIEndpoint { static const String quizSearch = "/quiz/search"; static const String historyQuiz = "/history"; + static const String detailHistoryQuiz = "/history/detail"; } diff --git a/lib/data/models/history/detail_quiz_history.dart b/lib/data/models/history/detail_quiz_history.dart new file mode 100644 index 0000000..226fb46 --- /dev/null +++ b/lib/data/models/history/detail_quiz_history.dart @@ -0,0 +1,75 @@ +class QuizAnswerResult { + final String answerId; + final String quizId; + final String title; + final String description; + final String authorId; + final String answeredAt; + final int totalCorrect; + final int totalScore; + final double totalSolveTime; + final List questionListings; + + QuizAnswerResult({ + required this.answerId, + required this.quizId, + required this.title, + required this.description, + required this.authorId, + required this.answeredAt, + required this.totalCorrect, + required this.totalScore, + required this.totalSolveTime, + required this.questionListings, + }); + + factory QuizAnswerResult.fromJson(Map json) { + return QuizAnswerResult( + answerId: json['answer_id'], + quizId: json['quiz_id'], + title: json['title'], + description: json['description'], + authorId: json['author_id'], + answeredAt: json['answered_at'], + totalCorrect: json['total_correct'], + totalScore: json['total_score'], + totalSolveTime: (json['total_solve_time'] as num).toDouble(), + questionListings: (json['question_listings'] as List).map((e) => QuestionAnswerItem.fromJson(e)).toList(), + ); + } +} + +class QuestionAnswerItem { + final int index; + final String question; + final String type; + final dynamic targetAnswer; + final dynamic userAnswer; + final bool isCorrect; + final double timeSpent; + final List? options; + + QuestionAnswerItem({ + required this.index, + required this.question, + required this.type, + required this.targetAnswer, + required this.userAnswer, + required this.isCorrect, + required this.timeSpent, + this.options, + }); + + factory QuestionAnswerItem.fromJson(Map json) { + return QuestionAnswerItem( + index: json['index'], + question: json['question'], + type: json['type'], + targetAnswer: json['target_answer'], + userAnswer: json['user_answer'], + isCorrect: json['is_correct'], + timeSpent: (json['time_spent'] as num).toDouble(), + options: json['options'] != null ? List.from(json['options']) : null, + ); + } +} diff --git a/lib/data/models/quiz/question/fill_in_the_blank_question_model.dart b/lib/data/models/quiz/question/fill_in_the_blank_question_model.dart index 63aa35c..b917875 100644 --- a/lib/data/models/quiz/question/fill_in_the_blank_question_model.dart +++ b/lib/data/models/quiz/question/fill_in_the_blank_question_model.dart @@ -4,11 +4,11 @@ class FillInTheBlankQuestion extends BaseQuestionModel { final String targetAnswer; FillInTheBlankQuestion({ - required int index, - required String question, - required int duration, + required super.index, + required super.question, + required super.duration, required this.targetAnswer, - }) : super(index: index, question: question, duration: duration, type: 'fill_the_blank'); + }) : super(type: 'fill_the_blank'); factory FillInTheBlankQuestion.fromJson(Map json) { return FillInTheBlankQuestion( diff --git a/lib/data/models/quiz/question/option_question_model.dart b/lib/data/models/quiz/question/option_question_model.dart index e04355a..9b5ce6f 100644 --- a/lib/data/models/quiz/question/option_question_model.dart +++ b/lib/data/models/quiz/question/option_question_model.dart @@ -5,12 +5,12 @@ class OptionQuestion extends BaseQuestionModel { final List options; OptionQuestion({ - required int index, - required String question, - required int duration, + required super.index, + required super.question, + required super.duration, required this.targetAnswer, required this.options, - }) : super(index: index, question: question, duration: duration, type: 'option'); + }) : super(type: 'option'); factory OptionQuestion.fromJson(Map json) { return OptionQuestion( diff --git a/lib/data/models/quiz/question/true_false_question_model.dart b/lib/data/models/quiz/question/true_false_question_model.dart index e4f0768..0a0717e 100644 --- a/lib/data/models/quiz/question/true_false_question_model.dart +++ b/lib/data/models/quiz/question/true_false_question_model.dart @@ -4,11 +4,11 @@ class TrueFalseQuestion extends BaseQuestionModel { final bool targetAnswer; TrueFalseQuestion({ - required int index, - required String question, - required int duration, + required super.index, + required super.question, + required super.duration, required this.targetAnswer, - }) : super(index: index, question: question, duration: duration, type: 'true_false'); + }) : super(type: 'true_false'); factory TrueFalseQuestion.fromJson(Map json) { return TrueFalseQuestion( diff --git a/lib/data/services/history_service.dart b/lib/data/services/history_service.dart index fa200f7..ef91028 100644 --- a/lib/data/services/history_service.dart +++ b/lib/data/services/history_service.dart @@ -3,6 +3,7 @@ import 'package:get/get.dart'; import 'package:quiz_app/core/endpoint/api_endpoint.dart'; import 'package:quiz_app/core/utils/logger.dart'; import 'package:quiz_app/data/models/base/base_model.dart'; +import 'package:quiz_app/data/models/history/detail_quiz_history.dart'; import 'package:quiz_app/data/models/history/quiz_history.dart'; import 'package:quiz_app/data/providers/dio_client.dart'; @@ -29,4 +30,19 @@ class HistoryService extends GetxService { return null; } } + + Future?> getDetailHistory(String answerId) async { + try { + final result = await _dio.get("${APIEndpoint.detailHistoryQuiz}/$answerId"); + + final parsedResponse = BaseResponseModel.fromJson( + result.data, + (data) => QuizAnswerResult.fromJson(data as Map), + ); + return parsedResponse; + } catch (e, stacktrace) { + logC.e(e, stackTrace: stacktrace); + return null; + } + } } diff --git a/lib/feature/detail_quiz/view/detail_quix_view.dart b/lib/feature/detail_quiz/view/detail_quix_view.dart index 6836916..d2e3afd 100644 --- a/lib/feature/detail_quiz/view/detail_quix_view.dart +++ b/lib/feature/detail_quiz/view/detail_quix_view.dart @@ -111,7 +111,7 @@ class DetailQuizView extends GetView { border: Border.all(color: AppColors.borderLight), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.05), + color: Colors.black.withValues(alpha: 0.05), blurRadius: 6, offset: const Offset(2, 2), ), diff --git a/lib/feature/history/binding/detail_history_binding.dart b/lib/feature/history/binding/detail_history_binding.dart new file mode 100644 index 0000000..5478a70 --- /dev/null +++ b/lib/feature/history/binding/detail_history_binding.dart @@ -0,0 +1,11 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/data/services/history_service.dart'; +import 'package:quiz_app/feature/history/controller/detail_history_controller.dart'; + +class DetailHistoryBinding extends Bindings { + @override + void dependencies() { + if (!Get.isRegistered()) Get.lazyPut(() => HistoryService()); + Get.lazyPut(() => DetailHistoryController(Get.find())); + } +} diff --git a/lib/feature/history/controller/detail_history_controller.dart b/lib/feature/history/controller/detail_history_controller.dart new file mode 100644 index 0000000..122d554 --- /dev/null +++ b/lib/feature/history/controller/detail_history_controller.dart @@ -0,0 +1,30 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/data/models/base/base_model.dart'; +import 'package:quiz_app/data/models/history/detail_quiz_history.dart'; +import 'package:quiz_app/data/services/history_service.dart'; + +class DetailHistoryController extends GetxController { + final HistoryService _historyService; + + DetailHistoryController(this._historyService); + + late QuizAnswerResult quizAnswer; + + RxBool isLoading = true.obs; + + @override + void onInit() { + _loadData(); + super.onInit(); + } + + void _loadData() async { + String answerId = Get.arguments as String; + BaseResponseModel? result = await _historyService.getDetailHistory(answerId); + if (result != null) { + if (result.data != null) quizAnswer = result.data!; + } + + isLoading.value = false; + } +} diff --git a/lib/feature/history/controller/history_controller.dart b/lib/feature/history/controller/history_controller.dart index 786e262..9801849 100644 --- a/lib/feature/history/controller/history_controller.dart +++ b/lib/feature/history/controller/history_controller.dart @@ -1,11 +1,12 @@ import 'package:get/get.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/models/history/quiz_history.dart'; import 'package:quiz_app/data/services/history_service.dart'; class HistoryController extends GetxController { - HistoryService _historyService; - UserController _userController; + final HistoryService _historyService; + final UserController _userController; HistoryController(this._historyService, this._userController); @@ -23,4 +24,6 @@ class HistoryController extends GetxController { historyList.value = await _historyService.getHistory(_userController.userData!.id) ?? []; isLoading.value = false; } + + void goToDetailHistory(String answerId) => Get.toNamed(AppRoutes.detailHistoryPage, arguments: answerId); } diff --git a/lib/feature/history/view/component/quiz_item_component.dart b/lib/feature/history/view/component/quiz_item_component.dart new file mode 100644 index 0000000..39cae80 --- /dev/null +++ b/lib/feature/history/view/component/quiz_item_component.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/data/models/history/detail_quiz_history.dart'; + +class QuizItemComponent extends StatelessWidget { + final QuestionAnswerItem item; + + const QuizItemComponent({super.key, required this.item}); + + @override + Widget build(BuildContext context) { + final bool isOptionType = item.type == 'option'; + + return Container( + width: double.infinity, + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildQuestionText(), + const SizedBox(height: 16), + if (isOptionType && item.options != null) _buildOptions(), + const SizedBox(height: 12), + _buildAnswerIndicator(), + const SizedBox(height: 16), + const Divider(height: 24), + _buildMetadata(), + ], + ), + ); + } + + Widget _buildQuestionText() { + return Text( + '${item.index}. ${item.question}', + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16, + ), + ); + } + + Widget _buildOptions() { + return Column( + children: item.options!.asMap().entries.map((entry) { + final int index = entry.key; + final String text = entry.value; + + final isCorrect = index == item.targetAnswer; + final isWrong = index == item.userAnswer && !isCorrect; + + final Color? backgroundColor = isCorrect + ? AppColors.primaryBlue.withValues(alpha: 0.15) + : isWrong + ? Colors.red.withValues(alpha: 0.15) + : null; + + return Container( + width: double.infinity, + margin: const EdgeInsets.symmetric(vertical: 6), + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), + ), + child: Row( + children: [ + Icon( + LucideIcons.circle, + size: 16, + color: Colors.grey.shade600, + ), + const SizedBox(width: 8), + Flexible( + child: Text( + text, + style: const TextStyle(fontSize: 14), + ), + ), + ], + ), + ); + }).toList(), + ); + } + + Widget _buildAnswerIndicator() { + final correctIcon = item.isCorrect ? LucideIcons.checkCircle2 : LucideIcons.xCircle; + final correctColor = item.isCorrect ? AppColors.primaryBlue : Colors.red; + + final String userAnswerText = item.type == 'option' ? item.options![item.userAnswer] : item.userAnswer.toString(); + + final String correctAnswerText = item.targetAnswer.toString(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(correctIcon, color: correctColor, size: 18), + const SizedBox(width: 8), + Text( + 'Jawabanmu: $userAnswerText', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + ], + ), + if (item.type != 'option' && !item.isCorrect) ...[ + const SizedBox(height: 6), + Row( + children: [ + const SizedBox(width: 26), // offset icon size + spacing + Text( + 'Jawaban benar: $correctAnswerText', + style: const TextStyle(fontSize: 14, color: Colors.black54), + ), + ], + ), + ], + ], + ); + } + + Widget _buildMetadata() { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _metaItem(icon: LucideIcons.helpCircle, label: item.type), + _metaItem(icon: LucideIcons.clock3, label: '${item.timeSpent}s'), + ], + ); + } + + Widget _metaItem({required IconData icon, required String label}) { + return Row( + children: [ + Icon(icon, size: 16, color: AppColors.primaryBlue), + const SizedBox(width: 6), + Text( + label, + style: const TextStyle(fontSize: 13, color: Colors.black54), + ), + ], + ); + } +} diff --git a/lib/feature/history/view/detail_history_view.dart b/lib/feature/history/view/detail_history_view.dart new file mode 100644 index 0000000..7e3f7db --- /dev/null +++ b/lib/feature/history/view/detail_history_view.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; +import 'package:get/get_state_manager/get_state_manager.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/component/widget/loading_widget.dart'; +import 'package:quiz_app/feature/history/controller/detail_history_controller.dart'; +import 'package:quiz_app/feature/history/view/component/quiz_item_component.dart'; + +class DetailHistoryView extends GetView { + const DetailHistoryView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.background, + elevation: 0, + title: const Text( + 'Detail history', + style: TextStyle( + color: AppColors.darkText, + fontWeight: FontWeight.bold, + ), + ), + centerTitle: true, + iconTheme: const IconThemeData(color: AppColors.darkText), + ), + body: SafeArea( + child: Obx(() { + if (controller.isLoading.value) { + return Expanded( + child: Center( + child: LoadingWidget(), + ), + ); + } + return ListView( + children: [ + quizMetaInfo(), + ...quizListings(), + ], + ); + }), + ), + ); + } + + List quizListings() { + return controller.quizAnswer.questionListings.map((e) => QuizItemComponent(item: e)).toList(); + } + + Widget quizMetaInfo() { + final quiz = controller.quizAnswer; + + return Container( + width: double.infinity, + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + quiz.title, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text( + quiz.description, + style: const TextStyle(fontSize: 14, color: Colors.black54), + ), + const SizedBox(height: 12), + Row( + children: [ + const Icon(LucideIcons.calendar, size: 16, color: Colors.black45), + const SizedBox(width: 6), + Text( + quiz.answeredAt, + style: const TextStyle(fontSize: 13, color: Colors.black54), + ), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + const Icon(LucideIcons.clock, size: 16, color: Colors.black45), + const SizedBox(width: 6), + Text( + '12:00', // tanggal dan jam dipisahkan titik tengah + style: const TextStyle(fontSize: 13, color: Colors.black54), + ), + ], + ), + const SizedBox(height: 6), + const Divider( + height: 24, + thickness: 1, + color: AppColors.shadowPrimary, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildStatItem( + icon: LucideIcons.checkCircle2, + label: 'Benar', + value: "${quiz.totalCorrect}/${quiz.questionListings.length}", + color: Colors.green, + ), + _buildStatItem( + icon: LucideIcons.award, + label: 'Skor', + value: quiz.totalScore.toString(), + color: Colors.blueAccent, + ), + _buildStatItem( + icon: LucideIcons.clock3, + label: 'Waktu', + value: '${quiz.totalSolveTime}s', + color: Colors.orange, + ), + ], + ), + ], + ), + ); + } + + Widget _buildStatItem({ + required IconData icon, + required String label, + required String value, + required Color color, + }) { + return Column( + children: [ + Icon(icon, color: color, size: 28), + const SizedBox(height: 4), + Text( + value, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + Text( + label, + style: const TextStyle(fontSize: 12, color: Colors.black54), + ), + ], + ); + } +} diff --git a/lib/feature/history/view/history_view.dart b/lib/feature/history/view/history_view.dart index e8ab89f..c7b7f77 100644 --- a/lib/feature/history/view/history_view.dart +++ b/lib/feature/history/view/history_view.dart @@ -67,67 +67,70 @@ class HistoryView extends GetView { } Widget _buildHistoryCard(QuizHistory item) { - return Container( - margin: const EdgeInsets.only(bottom: 16), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: const [ - BoxShadow( - color: Colors.black12, - blurRadius: 4, - offset: Offset(0, 2), - ) - ], - ), - child: Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: Colors.blue.shade100, - shape: BoxShape.circle, + return GestureDetector( + onTap: () => controller.goToDetailHistory(item.answerId), + child: Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 4, + offset: Offset(0, 2), + ) + ], + ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: Colors.blue.shade100, + shape: BoxShape.circle, + ), + child: const Icon(Icons.history, color: Colors.blue), ), - child: const Icon(Icons.history, color: Colors.blue), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.title, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), - ), - const SizedBox(height: 4), - Text( - item.date, - style: const TextStyle(fontSize: 12, color: Colors.grey), - ), - const SizedBox(height: 8), - Row( - children: [ - const Icon(Icons.check_circle, size: 14, color: Colors.green), - const SizedBox(width: 4), - Text( - "Skor: ${item.totalCorrect}/${item.totalQuestion}", - style: const TextStyle(fontSize: 12), - ), - const SizedBox(width: 16), - const Icon(Icons.timer, size: 14, color: Colors.grey), - const SizedBox(width: 4), - Text( - "3 menit", - style: const TextStyle(fontSize: 12), - ), - ], - ), - ], - ), - ) - ], + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 4), + Text( + item.date, + style: const TextStyle(fontSize: 12, color: Colors.grey), + ), + const SizedBox(height: 8), + Row( + children: [ + const Icon(Icons.check_circle, size: 14, color: Colors.green), + const SizedBox(width: 4), + Text( + "Skor: ${item.totalCorrect}/${item.totalQuestion}", + style: const TextStyle(fontSize: 12), + ), + const SizedBox(width: 16), + const Icon(Icons.timer, size: 14, color: Colors.grey), + const SizedBox(width: 4), + Text( + "3 menit", + style: const TextStyle(fontSize: 12), + ), + ], + ), + ], + ), + ) + ], + ), ), ); } diff --git a/lib/feature/home/view/component/button_option.dart b/lib/feature/home/view/component/button_option.dart index fe1647c..f42006c 100644 --- a/lib/feature/home/view/component/button_option.dart +++ b/lib/feature/home/view/component/button_option.dart @@ -88,7 +88,7 @@ class ButtonOption extends StatelessWidget { borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( - color: gradientColors.last.withOpacity(0.4), + color: gradientColors.last.withValues(alpha: 0.4), blurRadius: 6, offset: const Offset(2, 4), ), diff --git a/lib/feature/home/view/component/search_component.dart b/lib/feature/home/view/component/search_component.dart index 7409fa2..512b314 100644 --- a/lib/feature/home/view/component/search_component.dart +++ b/lib/feature/home/view/component/search_component.dart @@ -86,7 +86,7 @@ class SearchComponent extends StatelessWidget { borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.05), + color: Colors.black.withValues(alpha: 0.05), blurRadius: 6, offset: Offset(0, 2), ), diff --git a/lib/feature/library/view/library_view.dart b/lib/feature/library/view/library_view.dart index 916173f..74dc05a 100644 --- a/lib/feature/library/view/library_view.dart +++ b/lib/feature/library/view/library_view.dart @@ -74,7 +74,7 @@ class LibraryView extends GetView { borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.05), + color: Colors.black.withValues(alpha: 0.05), blurRadius: 6, offset: const Offset(0, 2), ), diff --git a/lib/feature/profile/controller/profile_controller.dart b/lib/feature/profile/controller/profile_controller.dart index a52dffe..95a4354 100644 --- a/lib/feature/profile/controller/profile_controller.dart +++ b/lib/feature/profile/controller/profile_controller.dart @@ -1,4 +1,5 @@ import 'package:get/get.dart'; +import 'package:quiz_app/core/utils/logger.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; class ProfileController extends GetxController { @@ -12,10 +13,10 @@ class ProfileController extends GetxController { final avgScore = 85.obs; void logout() { - print("Logout pressed"); + logC.i("Logout pressed"); } void editProfile() { - print("Edit profile pressed"); + logC.i("Edit profile pressed"); } } diff --git a/lib/feature/quiz_play/view/quiz_play_view.dart b/lib/feature/quiz_play/view/quiz_play_view.dart index e06e727..1006f80 100644 --- a/lib/feature/quiz_play/view/quiz_play_view.dart +++ b/lib/feature/quiz_play/view/quiz_play_view.dart @@ -1,7 +1,5 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:quiz_app/app/const/colors/app_colors.dart'; -import 'package:quiz_app/component/global_text_field.dart'; import 'package:quiz_app/feature/quiz_play/controller/quiz_play_controller.dart'; class QuizPlayView extends GetView { diff --git a/pubspec.lock b/pubspec.lock index 299040d..92b35f5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -232,6 +232,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.5.0" + lucide_icons: + dependency: "direct main" + description: + name: lucide_icons + sha256: ad24d0fd65707e48add30bebada7d90bff2a1bba0a72d6e9b19d44246b0e83c4 + url: "https://pub.dev" + source: hosted + version: "0.257.0" matcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index c731b77..757e6c0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,7 @@ dependencies: flutter_dotenv: ^5.2.1 dio: ^5.8.0+1 shared_preferences: ^2.5.3 + lucide_icons: ^0.257.0 dev_dependencies: flutter_test: From 572808a40dcd8e3e4031c954d5be2764e0851eec Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 4 May 2025 02:46:36 +0700 Subject: [PATCH 040/104] fix: adjustment on the detail history minor --- .../view/component/quiz_item_component.dart | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/lib/feature/history/view/component/quiz_item_component.dart b/lib/feature/history/view/component/quiz_item_component.dart index 39cae80..c4e107b 100644 --- a/lib/feature/history/view/component/quiz_item_component.dart +++ b/lib/feature/history/view/component/quiz_item_component.dart @@ -36,7 +36,10 @@ class QuizItemComponent extends StatelessWidget { const SizedBox(height: 12), _buildAnswerIndicator(), const SizedBox(height: 16), - const Divider(height: 24), + const Divider( + height: 24, + color: AppColors.shadowPrimary, + ), _buildMetadata(), ], ), @@ -59,14 +62,22 @@ class QuizItemComponent extends StatelessWidget { final int index = entry.key; final String text = entry.value; - final isCorrect = index == item.targetAnswer; - final isWrong = index == item.userAnswer && !isCorrect; + final bool isCorrectAnswer = index == item.targetAnswer; + final bool isUserWrongAnswer = index == item.userAnswer && !isCorrectAnswer; - final Color? backgroundColor = isCorrect - ? AppColors.primaryBlue.withValues(alpha: 0.15) - : isWrong - ? Colors.red.withValues(alpha: 0.15) - : null; + Color? backgroundColor; + IconData icon = LucideIcons.circle; + Color iconColor = AppColors.shadowPrimary; + + if (isCorrectAnswer) { + backgroundColor = AppColors.primaryBlue.withValues(alpha: 0.15); + icon = LucideIcons.checkCircle2; + iconColor = AppColors.primaryBlue; + } else if (isUserWrongAnswer) { + backgroundColor = Colors.red.withValues(alpha: 0.15); + icon = LucideIcons.xCircle; + iconColor = Colors.red; + } return Container( width: double.infinity, @@ -75,14 +86,14 @@ class QuizItemComponent extends StatelessWidget { decoration: BoxDecoration( color: backgroundColor, borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey.shade300), + border: Border.all(color: AppColors.shadowPrimary), ), child: Row( children: [ Icon( - LucideIcons.circle, + icon, size: 16, - color: Colors.grey.shade600, + color: iconColor, ), const SizedBox(width: 8), Flexible( From 140b8f103c54fb60ba3bb6039f33f8019357f1b4 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 4 May 2025 03:07:15 +0700 Subject: [PATCH 041/104] feat: adding global text style --- lib/app/const/text/text_style.dart | 55 ++++++++++++++++++ .../view/component/quiz_item_component.dart | 33 +++-------- .../history/view/detail_history_view.dart | 57 +++++-------------- lib/feature/history/view/history_view.dart | 49 ++++++---------- pubspec.lock | 40 +++++++++++++ pubspec.yaml | 1 + 6 files changed, 137 insertions(+), 98 deletions(-) create mode 100644 lib/app/const/text/text_style.dart diff --git a/lib/app/const/text/text_style.dart b/lib/app/const/text/text_style.dart new file mode 100644 index 0000000..f6697cc --- /dev/null +++ b/lib/app/const/text/text_style.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; + +class AppTextStyles { + /// Title: strong and modern using Roboto + static final TextStyle title = GoogleFonts.roboto( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppColors.darkText, + ); + + /// Subtitle: clean and readable using Inter + static final TextStyle subtitle = GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.softGrayText, + ); + + /// Body: neutral and easy-to-read using Inter + static final TextStyle body = GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.darkText, + ); + + /// Caption: friendly and soft using Nunito + static final TextStyle caption = GoogleFonts.nunito( + fontSize: 13, + fontWeight: FontWeight.w400, + color: AppColors.softGrayText, + ); + + /// Stat value: bold and standout using Poppins + static final TextStyle statValue = GoogleFonts.poppins( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.darkText, + ); + + /// Option text: clean and consistent using Inter + static final TextStyle optionText = GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.darkText, + ); + + /// DateTime: subtle and elegant using Nunito Italic + static final TextStyle dateTime = GoogleFonts.nunito( + fontSize: 13, + fontWeight: FontWeight.w400, + fontStyle: FontStyle.italic, + color: AppColors.softGrayText, + ); +} diff --git a/lib/feature/history/view/component/quiz_item_component.dart b/lib/feature/history/view/component/quiz_item_component.dart index c4e107b..37e109a 100644 --- a/lib/feature/history/view/component/quiz_item_component.dart +++ b/lib/feature/history/view/component/quiz_item_component.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/app/const/text/text_style.dart'; import 'package:quiz_app/data/models/history/detail_quiz_history.dart'; class QuizItemComponent extends StatelessWidget { @@ -36,10 +37,7 @@ class QuizItemComponent extends StatelessWidget { const SizedBox(height: 12), _buildAnswerIndicator(), const SizedBox(height: 16), - const Divider( - height: 24, - color: AppColors.shadowPrimary, - ), + const Divider(height: 24, color: AppColors.shadowPrimary), _buildMetadata(), ], ), @@ -49,10 +47,7 @@ class QuizItemComponent extends StatelessWidget { Widget _buildQuestionText() { return Text( '${item.index}. ${item.question}', - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 16, - ), + style: AppTextStyles.title.copyWith(fontSize: 16, fontWeight: FontWeight.w600), ); } @@ -90,17 +85,10 @@ class QuizItemComponent extends StatelessWidget { ), child: Row( children: [ - Icon( - icon, - size: 16, - color: iconColor, - ), + Icon(icon, size: 16, color: iconColor), const SizedBox(width: 8), Flexible( - child: Text( - text, - style: const TextStyle(fontSize: 14), - ), + child: Text(text, style: AppTextStyles.optionText), ), ], ), @@ -126,7 +114,7 @@ class QuizItemComponent extends StatelessWidget { const SizedBox(width: 8), Text( 'Jawabanmu: $userAnswerText', - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + style: AppTextStyles.statValue, ), ], ), @@ -134,10 +122,10 @@ class QuizItemComponent extends StatelessWidget { const SizedBox(height: 6), Row( children: [ - const SizedBox(width: 26), // offset icon size + spacing + const SizedBox(width: 26), // offset for icon + spacing Text( 'Jawaban benar: $correctAnswerText', - style: const TextStyle(fontSize: 14, color: Colors.black54), + style: AppTextStyles.caption, ), ], ), @@ -161,10 +149,7 @@ class QuizItemComponent extends StatelessWidget { children: [ Icon(icon, size: 16, color: AppColors.primaryBlue), const SizedBox(width: 6), - Text( - label, - style: const TextStyle(fontSize: 13, color: Colors.black54), - ), + Text(label, style: AppTextStyles.caption), ], ); } diff --git a/lib/feature/history/view/detail_history_view.dart b/lib/feature/history/view/detail_history_view.dart index 7e3f7db..eba49d4 100644 --- a/lib/feature/history/view/detail_history_view.dart +++ b/lib/feature/history/view/detail_history_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get_state_manager/get_state_manager.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/app/const/text/text_style.dart'; import 'package:quiz_app/component/widget/loading_widget.dart'; import 'package:quiz_app/feature/history/controller/detail_history_controller.dart'; import 'package:quiz_app/feature/history/view/component/quiz_item_component.dart'; @@ -16,12 +17,9 @@ class DetailHistoryView extends GetView { appBar: AppBar( backgroundColor: AppColors.background, elevation: 0, - title: const Text( + title: Text( 'Detail history', - style: TextStyle( - color: AppColors.darkText, - fontWeight: FontWeight.bold, - ), + style: AppTextStyles.title.copyWith(fontSize: 24), ), centerTitle: true, iconTheme: const IconThemeData(color: AppColors.darkText), @@ -29,11 +27,7 @@ class DetailHistoryView extends GetView { body: SafeArea( child: Obx(() { if (controller.isLoading.value) { - return Expanded( - child: Center( - child: LoadingWidget(), - ), - ); + return const Center(child: LoadingWidget()); } return ListView( children: [ @@ -62,7 +56,7 @@ class DetailHistoryView extends GetView { borderRadius: BorderRadius.circular(20), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.05), + color: Colors.black.withValues(alpha: 0.05), blurRadius: 8, offset: const Offset(0, 4), ), @@ -71,43 +65,31 @@ class DetailHistoryView extends GetView { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - quiz.title, - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), + Text(quiz.title, style: AppTextStyles.title), const SizedBox(height: 8), Text( quiz.description, - style: const TextStyle(fontSize: 14, color: Colors.black54), + textAlign: TextAlign.justify, + style: AppTextStyles.caption, ), const SizedBox(height: 12), Row( children: [ - const Icon(LucideIcons.calendar, size: 16, color: Colors.black45), + const Icon(LucideIcons.calendar, size: 16, color: AppColors.softGrayText), const SizedBox(width: 6), - Text( - quiz.answeredAt, - style: const TextStyle(fontSize: 13, color: Colors.black54), - ), + Text(quiz.answeredAt, style: AppTextStyles.dateTime), ], ), const SizedBox(height: 6), Row( children: [ - const Icon(LucideIcons.clock, size: 16, color: Colors.black45), + const Icon(LucideIcons.clock, size: 16, color: AppColors.softGrayText), const SizedBox(width: 6), - Text( - '12:00', // tanggal dan jam dipisahkan titik tengah - style: const TextStyle(fontSize: 13, color: Colors.black54), - ), + Text('12:00', style: AppTextStyles.dateTime), // Replace with quiz.timeAnswered if available ], ), const SizedBox(height: 6), - const Divider( - height: 24, - thickness: 1, - color: AppColors.shadowPrimary, - ), + const Divider(height: 24, thickness: 1, color: AppColors.shadowPrimary), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ @@ -146,17 +128,8 @@ class DetailHistoryView extends GetView { children: [ Icon(icon, color: color, size: 28), const SizedBox(height: 4), - Text( - value, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - Text( - label, - style: const TextStyle(fontSize: 12, color: Colors.black54), - ), + Text(value, style: AppTextStyles.statValue), + Text(label, style: AppTextStyles.caption), ], ); } diff --git a/lib/feature/history/view/history_view.dart b/lib/feature/history/view/history_view.dart index c7b7f77..1582446 100644 --- a/lib/feature/history/view/history_view.dart +++ b/lib/feature/history/view/history_view.dart @@ -1,51 +1,45 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/app/const/text/text_style.dart'; import 'package:quiz_app/component/widget/loading_widget.dart'; import 'package:quiz_app/data/models/history/quiz_history.dart'; import 'package:quiz_app/feature/history/controller/history_controller.dart'; class HistoryView extends GetView { const HistoryView({super.key}); + @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: const Color(0xFFF8F9FB), + backgroundColor: AppColors.background, body: SafeArea( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - "Riwayat Kuis", - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), + Text("Riwayat Kuis", style: AppTextStyles.title.copyWith(fontSize: 24)), const SizedBox(height: 8), - const Text( + Text( "Lihat kembali hasil kuis yang telah kamu kerjakan", - style: TextStyle( - fontSize: 14, - color: Colors.grey, - ), + style: AppTextStyles.subtitle, ), const SizedBox(height: 20), Obx(() { if (controller.isLoading.value) { - return Expanded( - child: Center( - child: LoadingWidget(), - ), + return const Expanded( + child: Center(child: LoadingWidget()), ); } final historyList = controller.historyList; if (historyList.isEmpty) { - return const Expanded( - child: Center(child: Text("you still doesnt have quiz history")), + return Expanded( + child: Center( + child: Text("You don't have any quiz history yet", style: AppTextStyles.body), + ), ); } @@ -99,15 +93,9 @@ class HistoryView extends GetView { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - item.title, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), - ), + Text(item.title, style: AppTextStyles.statValue), const SizedBox(height: 4), - Text( - item.date, - style: const TextStyle(fontSize: 12, color: Colors.grey), - ), + Text(item.date, style: AppTextStyles.caption), const SizedBox(height: 8), Row( children: [ @@ -115,15 +103,12 @@ class HistoryView extends GetView { const SizedBox(width: 4), Text( "Skor: ${item.totalCorrect}/${item.totalQuestion}", - style: const TextStyle(fontSize: 12), + style: AppTextStyles.caption, ), const SizedBox(width: 16), const Icon(Icons.timer, size: 14, color: Colors.grey), const SizedBox(width: 4), - Text( - "3 menit", - style: const TextStyle(fontSize: 12), - ), + Text("3 menit", style: AppTextStyles.caption), ], ), ], diff --git a/pubspec.lock b/pubspec.lock index 92b35f5..942d1fc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" cupertino_icons: dependency: "direct main" description: @@ -128,6 +136,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.6.6" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82 + url: "https://pub.dev" + source: hosted + version: "6.2.1" google_identity_services_web: dependency: transitive description: @@ -272,6 +288,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + url: "https://pub.dev" + source: hosted + version: "2.2.17" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" + source: hosted + version: "2.4.1" path_provider_linux: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 757e6c0..cad9a05 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,6 +41,7 @@ dependencies: dio: ^5.8.0+1 shared_preferences: ^2.5.3 lucide_icons: ^0.257.0 + google_fonts: ^6.1.0 dev_dependencies: flutter_test: From 488479befaf37f01ed4eb7422971d95f61722a46 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 4 May 2025 11:40:32 +0700 Subject: [PATCH 042/104] feat: google sign in auth service --- lib/data/controllers/user_controller.dart | 7 ++++ lib/data/services/google_auth_service.dart | 35 +++++++++++++++++++ lib/feature/login/bindings/login_binding.dart | 4 ++- .../login/controllers/login_controller.dart | 25 +++++++------ .../profile/binding/profile_binding.dart | 5 ++- .../controller/profile_controller.dart | 23 ++++++++++-- 6 files changed, 82 insertions(+), 17 deletions(-) create mode 100644 lib/data/services/google_auth_service.dart diff --git a/lib/data/controllers/user_controller.dart b/lib/data/controllers/user_controller.dart index 35d5d4b..0ee2a54 100644 --- a/lib/data/controllers/user_controller.dart +++ b/lib/data/controllers/user_controller.dart @@ -39,4 +39,11 @@ class UserController extends GetxController { userImage.value = userEntity.picUrl; email.value = userEntity.email; } + + void clearUser() { + userData = null; + userName.value = ""; + userImage.value = ""; + email.value = ''; + } } diff --git a/lib/data/services/google_auth_service.dart b/lib/data/services/google_auth_service.dart new file mode 100644 index 0000000..9ad6096 --- /dev/null +++ b/lib/data/services/google_auth_service.dart @@ -0,0 +1,35 @@ +import 'package:google_sign_in/google_sign_in.dart'; + +class GoogleAuthService { + final GoogleSignIn _googleSignIn = GoogleSignIn( + scopes: ['email', 'profile', 'openid'], + ); + + Future signIn() async { + try { + return await _googleSignIn.signIn(); + } catch (e) { + rethrow; + } + } + + Future signOut() async { + try { + await _googleSignIn.signOut(); + } catch (e) { + rethrow; + } + } + + Future getIdToken() async { + final account = await _googleSignIn.signIn(); + if (account == null) return null; + + final auth = await account.authentication; + return auth.idToken; + } + + Future isSignedIn() async { + return await _googleSignIn.isSignedIn(); + } +} diff --git a/lib/feature/login/bindings/login_binding.dart b/lib/feature/login/bindings/login_binding.dart index 6c7e36f..9686a88 100644 --- a/lib/feature/login/bindings/login_binding.dart +++ b/lib/feature/login/bindings/login_binding.dart @@ -2,13 +2,15 @@ import 'package:get/get_core/get_core.dart'; import 'package:get/get_instance/get_instance.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/services/auth_service.dart'; +import 'package:quiz_app/data/services/google_auth_service.dart'; import 'package:quiz_app/data/services/user_storage_service.dart'; import 'package:quiz_app/feature/login/controllers/login_controller.dart'; class LoginBinding extends Bindings { @override void dependencies() { + Get.lazyPut(() => GoogleAuthService()); Get.lazyPut(() => AuthService()); - Get.lazyPut(() => LoginController(Get.find(), Get.find(), Get.find())); + Get.lazyPut(() => LoginController(Get.find(), Get.find(), Get.find(), Get.find())); } } diff --git a/lib/feature/login/controllers/login_controller.dart b/lib/feature/login/controllers/login_controller.dart index 07f3e2d..2dd8f6b 100644 --- a/lib/feature/login/controllers/login_controller.dart +++ b/lib/feature/login/controllers/login_controller.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:google_sign_in/google_sign_in.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/component/global_button.dart'; import 'package:quiz_app/core/utils/logger.dart'; @@ -9,14 +8,21 @@ import 'package:quiz_app/data/entity/user/user_entity.dart'; import 'package:quiz_app/data/models/login/login_request_model.dart'; import 'package:quiz_app/data/models/login/login_response_model.dart'; import 'package:quiz_app/data/services/auth_service.dart'; +import 'package:quiz_app/data/services/google_auth_service.dart'; import 'package:quiz_app/data/services/user_storage_service.dart'; class LoginController extends GetxController { final AuthService _authService; final UserStorageService _userStorageService; final UserController _userController; + final GoogleAuthService _googleAuthService; - LoginController(this._authService, this._userStorageService, this._userController); + LoginController( + this._authService, + this._userStorageService, + this._userController, + this._googleAuthService, + ); final TextEditingController emailController = TextEditingController(); final TextEditingController passwordController = TextEditingController(); @@ -25,10 +31,6 @@ class LoginController extends GetxController { final RxBool isPasswordHidden = true.obs; final RxBool isLoading = false.obs; - final GoogleSignIn _googleSignIn = GoogleSignIn( - scopes: ['email', 'profile', 'openid'], - ); - @override void onInit() { super.onInit(); @@ -81,22 +83,19 @@ class LoginController extends GetxController { /// **🔹 Login via Google** Future loginWithGoogle() async { try { - final GoogleSignInAccount? googleUser = await _googleSignIn.signIn(); - if (googleUser == null) { + final user = await _googleAuthService.signIn(); + if (user == null) { Get.snackbar("Error", "Google Sign-In canceled"); return; } - final GoogleSignInAuthentication googleAuth = await googleUser.authentication; - final idToken = googleAuth.idToken; - + final idToken = await user.authentication.then((auth) => auth.idToken); if (idToken == null || idToken.isEmpty) { - Get.snackbar("Error", "Google sign-in failed. No ID Token received."); + Get.snackbar("Error", "No ID Token received."); return; } final LoginResponseModel response = await _authService.loginWithGoogle(idToken); - final userEntity = _convertLoginResponseToUserEntity(response); await _userStorageService.saveUser(userEntity); diff --git a/lib/feature/profile/binding/profile_binding.dart b/lib/feature/profile/binding/profile_binding.dart index e8d0a25..89e85fd 100644 --- a/lib/feature/profile/binding/profile_binding.dart +++ b/lib/feature/profile/binding/profile_binding.dart @@ -1,9 +1,12 @@ import 'package:get/get.dart'; +import 'package:quiz_app/data/services/google_auth_service.dart'; +import 'package:quiz_app/data/services/user_storage_service.dart'; import 'package:quiz_app/feature/profile/controller/profile_controller.dart'; class ProfileBinding extends Bindings { @override void dependencies() { - Get.lazyPut(() => ProfileController()); + Get.lazyPut(() => GoogleAuthService()); + Get.lazyPut(() => ProfileController(Get.find(), Get.find())); } } diff --git a/lib/feature/profile/controller/profile_controller.dart b/lib/feature/profile/controller/profile_controller.dart index 95a4354..52f29f0 100644 --- a/lib/feature/profile/controller/profile_controller.dart +++ b/lib/feature/profile/controller/profile_controller.dart @@ -1,10 +1,18 @@ import 'package:get/get.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/core/utils/logger.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/services/google_auth_service.dart'; +import 'package:quiz_app/data/services/user_storage_service.dart'; class ProfileController extends GetxController { final UserController _userController = Get.find(); + final UserStorageService _userStorageService; + final GoogleAuthService _googleAuthService; + + ProfileController(this._userStorageService, this._googleAuthService); + Rx get userName => _userController.userName; Rx get email => _userController.email; Rx get userImage => _userController.userImage; @@ -12,8 +20,19 @@ class ProfileController extends GetxController { final totalQuizzes = 12.obs; final avgScore = 85.obs; - void logout() { - logC.i("Logout pressed"); + void logout() async { + try { + await _googleAuthService.signOut(); + + await _userStorageService.clearUser(); + _userController.clearUser(); + _userStorageService.isLogged = false; + + Get.offAllNamed(AppRoutes.loginPage); + } catch (e, stackTrace) { + logC.e("Google Sign-Out Error: $e", stackTrace: stackTrace); + Get.snackbar("Error", "Gagal logout dari Google"); + } } void editProfile() { From 55d96c3bafc9f0ce028da2597bf5c81185274cad Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 4 May 2025 21:53:20 +0700 Subject: [PATCH 043/104] fix: game play on quiz --- .../controller/quiz_play_controller.dart | 60 ++++--- .../quiz_play/view/quiz_play_view.dart | 77 ++++---- .../controller/quiz_preview_controller.dart | 2 +- .../controller/quiz_result_controller.dart | 64 ++----- .../component/quiz_item_wa_component.dart | 167 ++++++++++++++++++ .../quiz_result/view/quiz_result_view.dart | 80 ++++++--- 6 files changed, 316 insertions(+), 134 deletions(-) create mode 100644 lib/feature/quiz_result/view/component/quiz_item_wa_component.dart diff --git a/lib/feature/quiz_play/controller/quiz_play_controller.dart b/lib/feature/quiz_play/controller/quiz_play_controller.dart index ed5bd01..b1969ec 100644 --- a/lib/feature/quiz_play/controller/quiz_play_controller.dart +++ b/lib/feature/quiz_play/controller/quiz_play_controller.dart @@ -6,6 +6,9 @@ import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/component/notification/pop_up_confirmation.dart'; import 'package:quiz_app/data/models/quiz/library_quiz_model.dart'; import 'package:quiz_app/data/models/quiz/question/base_qustion_model.dart'; +import 'package:quiz_app/data/models/quiz/question/fill_in_the_blank_question_model.dart'; +import 'package:quiz_app/data/models/quiz/question/option_question_model.dart'; +import 'package:quiz_app/data/models/quiz/question/true_false_question_model.dart'; class QuizPlayController extends GetxController { late final QuizData quizData; @@ -88,25 +91,35 @@ class QuizPlayController extends GetxController { void _submitAnswerIfNeeded() { final question = currentQuestion; - String userAnswer = ''; + dynamic userAnswer = ''; - switch (question.type) { - case 'fill_the_blank': - userAnswer = answerTextController.text.trim(); - break; - case 'option': - case 'true_false': - userAnswer = selectedAnswer.value.trim(); - break; + if (question is FillInTheBlankQuestion) { + userAnswer = answerTextController.text.toString(); + } else { + userAnswer = selectedAnswer.value; } - // answeredQuestions.add(AnsweredQuestion( - // index: currentIndex.value, - // questionIndex: question.index, - // selectedAnswer: userAnswer, - // correctAnswer: question., - // isCorrect: userAnswer.toLowerCase() == question.targetAnswer.trim().toLowerCase(), - // duration: currentQuestion.duration - timeLeft.value, - // )); + + dynamic correctAnswer; + if (question is FillInTheBlankQuestion) { + correctAnswer = question.targetAnswer.toLowerCase(); + userAnswer = userAnswer.toString().toLowerCase(); + } else if (question is TrueFalseQuestion) { + correctAnswer = question.targetAnswer; + userAnswer = userAnswer == 'true' || userAnswer == true; + } else if (question is OptionQuestion) { + correctAnswer = question.targetAnswer; + userAnswer = int.tryParse(userAnswer.toString()); + } + + final isCorrect = userAnswer == correctAnswer; + + answeredQuestions.add(AnsweredQuestion( + index: currentIndex.value, + questionIndex: question.index, + selectedAnswer: userAnswer, + isCorrect: isCorrect, + duration: question.duration - timeLeft.value, + )); } void nextQuestion() { @@ -138,10 +151,17 @@ class QuizPlayController extends GetxController { _timer?.cancel(); AppDialog.showMessage(Get.context!, "Yeay semua soal selesai"); + await Future.delayed(Duration(seconds: 2)); + + print(quizData); + Get.offAllNamed( AppRoutes.resultQuizPage, - arguments: [quizData, answeredQuestions], + arguments: { + "quiz_data": quizData, + "answer_data": answeredQuestions, + }, ); } @@ -157,7 +177,6 @@ class AnsweredQuestion { final int index; final int questionIndex; final dynamic selectedAnswer; - final dynamic correctAnswer; final bool isCorrect; final int duration; @@ -165,7 +184,6 @@ class AnsweredQuestion { required this.index, required this.questionIndex, required this.selectedAnswer, - required this.correctAnswer, required this.isCorrect, required this.duration, }); @@ -175,7 +193,6 @@ class AnsweredQuestion { index: json['index'], questionIndex: json['question_index'], selectedAnswer: json['selectedAnswer'], - correctAnswer: json['correctAnswer'], isCorrect: json['isCorrect'], duration: json['duration'], ); @@ -185,7 +202,6 @@ class AnsweredQuestion { 'index': index, 'question_index': questionIndex, 'selectedAnswer': selectedAnswer, - 'correctAnswer': correctAnswer, 'isCorrect': isCorrect, 'duration': duration, }; diff --git a/lib/feature/quiz_play/view/quiz_play_view.dart b/lib/feature/quiz_play/view/quiz_play_view.dart index 1006f80..5fc9989 100644 --- a/lib/feature/quiz_play/view/quiz_play_view.dart +++ b/lib/feature/quiz_play/view/quiz_play_view.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/component/global_text_field.dart'; +import 'package:quiz_app/data/models/quiz/question/option_question_model.dart'; import 'package:quiz_app/feature/quiz_play/controller/quiz_play_controller.dart'; class QuizPlayView extends GetView { @@ -33,7 +36,7 @@ class QuizPlayView extends GetView { const SizedBox(height: 12), _buildQuestionText(), const SizedBox(height: 30), - // _buildAnswerSection(), + _buildAnswerSection(), const Spacer(), _buildNextButton(), ], @@ -98,44 +101,44 @@ class QuizPlayView extends GetView { ); } - // Widget _buildAnswerSection() { - // final question = controller.currentQuestion; + Widget _buildAnswerSection() { + final question = controller.currentQuestion; - // if (question.type == 'option' && question.options != null) { - // return Column( - // children: List.generate(question.options!.length, (index) { - // final option = question.options![index]; - // final isSelected = controller.idxOptionSelected.value == index; + if (question is OptionQuestion) { + return Column( + children: List.generate(question.options.length, (index) { + final option = question.options[index]; + final isSelected = controller.idxOptionSelected.value == index; - // return Container( - // margin: const EdgeInsets.only(bottom: 12), - // width: double.infinity, - // child: ElevatedButton( - // style: ElevatedButton.styleFrom( - // backgroundColor: isSelected ? AppColors.primaryBlue : Colors.white, - // foregroundColor: isSelected ? Colors.white : Colors.black, - // side: const BorderSide(color: Colors.grey), - // padding: const EdgeInsets.symmetric(vertical: 14), - // shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - // ), - // onPressed: () => controller.selectAnswerOption(index), - // child: Text(option), - // ), - // ); - // }), - // ); - // } else if (question.type == 'true_false') { - // return Row( - // mainAxisAlignment: MainAxisAlignment.spaceEvenly, - // children: [ - // _buildTrueFalseButton('Ya', true, controller.choosenAnswerTOF), - // _buildTrueFalseButton('Tidak', false, controller.choosenAnswerTOF), - // ], - // ); - // } else { - // return GlobalTextField(controller: controller.answerTextController); - // } - // } + return Container( + margin: const EdgeInsets.only(bottom: 12), + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: isSelected ? AppColors.primaryBlue : Colors.white, + foregroundColor: isSelected ? Colors.white : Colors.black, + side: const BorderSide(color: Colors.grey), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + onPressed: () => controller.selectAnswerOption(index), + child: Text(option), + ), + ); + }), + ); + } else if (question.type == 'true_false') { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildTrueFalseButton('Ya', true, controller.choosenAnswerTOF), + _buildTrueFalseButton('Tidak', false, controller.choosenAnswerTOF), + ], + ); + } else { + return GlobalTextField(controller: controller.answerTextController); + } + } Widget _buildTrueFalseButton(String label, bool value, RxInt choosenAnswer) { return Obx(() { diff --git a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart index 03d87d4..66d0da7 100644 --- a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart +++ b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart @@ -32,7 +32,7 @@ class QuizPreviewController extends GetxController { if (Get.arguments is List) { data = Get.arguments as List; } else { - data = []; // Default aman supaya gak crash + data = []; Get.snackbar('Error', 'Data soal tidak ditemukan'); } } diff --git a/lib/feature/quiz_result/controller/quiz_result_controller.dart b/lib/feature/quiz_result/controller/quiz_result_controller.dart index 5bf445a..c436df3 100644 --- a/lib/feature/quiz_result/controller/quiz_result_controller.dart +++ b/lib/feature/quiz_result/controller/quiz_result_controller.dart @@ -1,14 +1,12 @@ import 'package:get/get.dart'; -import 'package:quiz_app/app/const/enums/question_type.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/data/models/quiz/library_quiz_model.dart'; -import 'package:quiz_app/data/models/quiz/question_listings_model.dart'; -import 'package:quiz_app/data/models/quiz/quiestion_data_model.dart'; +import 'package:quiz_app/data/models/quiz/question/base_qustion_model.dart'; import 'package:quiz_app/feature/quiz_play/controller/quiz_play_controller.dart'; class QuizResultController extends GetxController { late final QuizData question; - late final List questions; + late final List questions; late final List answers; RxInt correctAnswers = 0.obs; @@ -23,11 +21,12 @@ class QuizResultController extends GetxController { } void loadData() { - final args = Get.arguments as List; + final args = Get.arguments; - question = args[0] as QuizData; - // questions = question.questionListings; - answers = args[1] as List; + question = args['quiz_data'] as QuizData; + answers = args["answer_data"] as List; + + questions = question.questionListings; totalQuestions.value = questions.length; } @@ -40,52 +39,9 @@ class QuizResultController extends GetxController { } String getResultMessage() { - return "Nilai kamu ${scorePercentage.value}"; - } - - QuestionData mapQuestionListingToQuestionData(QuestionListing questionListing, int index) { - // Convert type string ke enum - QuestionType? questionType; - switch (questionListing.type) { - case 'fill_the_blank': - questionType = QuestionType.fillTheBlank; - break; - case 'option': - questionType = QuestionType.option; - break; - case 'true_false': - questionType = QuestionType.trueOrFalse; - break; - default: - questionType = null; - } - - // Convert options ke OptionData - List? optionDataList; - if (questionListing.options != null) { - optionDataList = []; - for (int i = 0; i < questionListing.options!.length; i++) { - optionDataList.add(OptionData(index: i, text: questionListing.options![i])); - } - } - - // Cari correctAnswerIndex kalau tipe-nya option - int? correctAnswerIndex; - if (questionType == QuestionType.option && optionDataList != null) { - correctAnswerIndex = optionDataList.indexWhere((option) => option.text == questionListing.targetAnswer); - if (correctAnswerIndex == -1) { - correctAnswerIndex = null; // Kalau tidak ketemu - } - } - - return QuestionData( - index: index, - question: questionListing.question, - answer: questionListing.targetAnswer, - options: optionDataList, - correctAnswerIndex: correctAnswerIndex, - type: questionType, - ); + double value = scorePercentage.value; + String formatted = value % 1 == 0 ? value.toStringAsFixed(0) : value.toStringAsFixed(1); + return "Nilai kamu $formatted"; } void onPopInvoke(bool isPop, dynamic value) { diff --git a/lib/feature/quiz_result/view/component/quiz_item_wa_component.dart b/lib/feature/quiz_result/view/component/quiz_item_wa_component.dart new file mode 100644 index 0000000..e6c92ed --- /dev/null +++ b/lib/feature/quiz_result/view/component/quiz_item_wa_component.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/app/const/text/text_style.dart'; + +class QuizItemWAComponent extends StatelessWidget { + final int index; + final String question; + final String type; + final dynamic userAnswer; + final dynamic targetAnswer; + final bool isCorrect; + final double timeSpent; + final List? options; + + const QuizItemWAComponent({ + super.key, + required this.index, + required this.question, + required this.type, + required this.userAnswer, + required this.targetAnswer, + required this.isCorrect, + required this.timeSpent, + this.options, + }); + + bool get isOptionType => type == 'option'; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$index. $question', + style: AppTextStyles.title.copyWith(fontSize: 16, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 16), + if (isOptionType && options != null) _buildOptions(), + const SizedBox(height: 12), + _buildAnswerIndicator(), + const SizedBox(height: 16), + const Divider(height: 24, color: AppColors.shadowPrimary), + _buildMetadata(), + ], + ), + ); + } + + Widget _buildOptions() { + return Column( + children: options!.asMap().entries.map((entry) { + final int optIndex = entry.key; + final String text = entry.value; + + final bool isCorrectAnswer = optIndex == targetAnswer; + final bool isUserWrongAnswer = optIndex == userAnswer && !isCorrectAnswer; + + Color? backgroundColor; + IconData icon = LucideIcons.circle; + Color iconColor = AppColors.shadowPrimary; + + if (isCorrectAnswer) { + backgroundColor = AppColors.primaryBlue.withOpacity(0.15); + icon = LucideIcons.checkCircle2; + iconColor = AppColors.primaryBlue; + } else if (isUserWrongAnswer) { + backgroundColor = Colors.red.withOpacity(0.15); + icon = LucideIcons.xCircle; + iconColor = Colors.red; + } + + return Container( + width: double.infinity, + margin: const EdgeInsets.symmetric(vertical: 6), + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.shadowPrimary), + ), + child: Row( + children: [ + Icon(icon, size: 16, color: iconColor), + const SizedBox(width: 8), + Flexible( + child: Text(text, style: AppTextStyles.optionText), + ), + ], + ), + ); + }).toList(), + ); + } + + Widget _buildAnswerIndicator() { + final icon = isCorrect ? LucideIcons.checkCircle2 : LucideIcons.xCircle; + final color = isCorrect ? AppColors.primaryBlue : Colors.red; + + final String userAnswerText = isOptionType ? options![userAnswer] : userAnswer.toString(); + final String correctAnswerText = targetAnswer.toString(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 8), + Text( + 'Jawabanmu: $userAnswerText', + style: AppTextStyles.statValue, + ), + ], + ), + if (!isCorrect && !isOptionType) ...[ + const SizedBox(height: 6), + Row( + children: [ + const SizedBox(width: 26), + Text( + 'Jawaban benar: $correctAnswerText', + style: AppTextStyles.caption, + ), + ], + ), + ], + ], + ); + } + + Widget _buildMetadata() { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _metaItem(icon: LucideIcons.helpCircle, label: type), + _metaItem(icon: LucideIcons.clock3, label: '${timeSpent.toStringAsFixed(1)}s'), + ], + ); + } + + Widget _metaItem({required IconData icon, required String label}) { + return Row( + children: [ + Icon(icon, size: 16, color: AppColors.primaryBlue), + const SizedBox(width: 6), + Text(label, style: AppTextStyles.caption), + ], + ); + } +} diff --git a/lib/feature/quiz_result/view/quiz_result_view.dart b/lib/feature/quiz_result/view/quiz_result_view.dart index e15f974..5de6962 100644 --- a/lib/feature/quiz_result/view/quiz_result_view.dart +++ b/lib/feature/quiz_result/view/quiz_result_view.dart @@ -1,8 +1,12 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:lucide_icons/lucide_icons.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; -import 'package:quiz_app/component/widget/question_container_widget.dart'; +import 'package:quiz_app/data/models/quiz/question/fill_in_the_blank_question_model.dart'; +import 'package:quiz_app/data/models/quiz/question/option_question_model.dart'; +import 'package:quiz_app/data/models/quiz/question/true_false_question_model.dart'; import 'package:quiz_app/feature/quiz_result/controller/quiz_result_controller.dart'; +import 'package:quiz_app/feature/quiz_result/view/component/quiz_item_wa_component.dart'; class QuizResultView extends GetView { const QuizResultView({super.key}); @@ -20,21 +24,11 @@ class QuizResultView extends GetView { child: Obx(() => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildCustomAppBar(context), + _buildAppBar(context), const SizedBox(height: 16), _buildScoreSummary(), const SizedBox(height: 16), - Expanded( - child: ListView.builder( - itemCount: controller.questions.length, - itemBuilder: (context, index) { - return QuestionContainerWidget( - question: controller.mapQuestionListingToQuestionData(controller.questions[index], index), - answeredQuestion: controller.answers[index], - ); - }, - ), - ), + _buildQuizList(), ], )), ), @@ -43,14 +37,12 @@ class QuizResultView extends GetView { ); } - Widget _buildCustomAppBar(BuildContext context) { + Widget _buildAppBar(BuildContext context) { return Row( children: [ IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.black), - onPressed: () { - controller.onPopInvoke(true, null); - }, + icon: const Icon(LucideIcons.arrowLeft, color: Colors.black), + onPressed: () => controller.onPopInvoke(true, null), ), const SizedBox(width: 8), const Text( @@ -62,6 +54,7 @@ class QuizResultView extends GetView { } Widget _buildScoreSummary() { + final score = controller.scorePercentage.value; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -71,7 +64,7 @@ class QuizResultView extends GetView { ), const SizedBox(height: 8), LinearProgressIndicator( - value: controller.scorePercentage.value / 100, + value: score / 100, minHeight: 10, backgroundColor: AppColors.borderLight, valueColor: const AlwaysStoppedAnimation(AppColors.primaryBlue), @@ -81,10 +74,57 @@ class QuizResultView extends GetView { controller.getResultMessage(), style: TextStyle( fontSize: 16, - color: controller.scorePercentage.value >= 80 ? Colors.green : Colors.red, + color: score >= 80 ? Colors.green : Colors.red, ), ), ], ); } + + Widget _buildQuizList() { + return Expanded( + child: ListView.builder( + itemCount: controller.questions.length, + itemBuilder: (context, index) { + final answer = controller.answers[index]; + final question = controller.questions.firstWhere( + (q) => q.index == answer.questionIndex, + orElse: () => throw Exception("Question not found"), + ); + + final parsed = _parseAnswer(question, answer.selectedAnswer); + + return QuizItemWAComponent( + index: index, + isCorrect: answer.isCorrect, + question: question.question, + targetAnswer: parsed.targetAnswer, + userAnswer: parsed.userAnswer, + timeSpent: answer.duration.toDouble(), + type: question.type, + options: parsed.options, + ); + }, + ), + ); + } + + /// Helper class for parsed answer details + ({dynamic userAnswer, dynamic targetAnswer, List options}) _parseAnswer(dynamic question, dynamic selectedAnswer) { + switch (question.type) { + case 'fill_the_blank': + final q = question as FillInTheBlankQuestion; + return (userAnswer: selectedAnswer.toString(), targetAnswer: q.targetAnswer, options: []); + case 'option': + final q = question as OptionQuestion; + final parsedAnswer = int.tryParse(selectedAnswer.toString()) ?? -1; + return (userAnswer: parsedAnswer, targetAnswer: q.targetAnswer, options: q.options); + case 'true_false': + final q = question as TrueFalseQuestion; + final boolAnswer = selectedAnswer is bool ? selectedAnswer : selectedAnswer.toString().toLowerCase() == 'true'; + return (userAnswer: boolAnswer, targetAnswer: q.targetAnswer, options: []); + default: + throw Exception("Unknown question type: ${question.type}"); + } + } } From 1a465fd0d1ece5129e9b8e37cf9478ea4201097e Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 4 May 2025 22:06:02 +0700 Subject: [PATCH 044/104] fix: make the quiz answer component into global --- .../widget}/quiz_item_wa_component.dart | 0 .../view/component/quiz_item_component.dart | 280 +++++++++--------- .../history/view/detail_history_view.dart | 15 +- .../quiz_result/view/quiz_result_view.dart | 2 +- 4 files changed, 154 insertions(+), 143 deletions(-) rename lib/{feature/quiz_result/view/component => component/widget}/quiz_item_wa_component.dart (100%) diff --git a/lib/feature/quiz_result/view/component/quiz_item_wa_component.dart b/lib/component/widget/quiz_item_wa_component.dart similarity index 100% rename from lib/feature/quiz_result/view/component/quiz_item_wa_component.dart rename to lib/component/widget/quiz_item_wa_component.dart diff --git a/lib/feature/history/view/component/quiz_item_component.dart b/lib/feature/history/view/component/quiz_item_component.dart index 37e109a..ca5002d 100644 --- a/lib/feature/history/view/component/quiz_item_component.dart +++ b/lib/feature/history/view/component/quiz_item_component.dart @@ -1,156 +1,156 @@ -import 'package:flutter/material.dart'; -import 'package:lucide_icons/lucide_icons.dart'; -import 'package:quiz_app/app/const/colors/app_colors.dart'; -import 'package:quiz_app/app/const/text/text_style.dart'; -import 'package:quiz_app/data/models/history/detail_quiz_history.dart'; +// import 'package:flutter/material.dart'; +// import 'package:lucide_icons/lucide_icons.dart'; +// import 'package:quiz_app/app/const/colors/app_colors.dart'; +// import 'package:quiz_app/app/const/text/text_style.dart'; +// import 'package:quiz_app/data/models/history/detail_quiz_history.dart'; -class QuizItemComponent extends StatelessWidget { - final QuestionAnswerItem item; +// class QuizItemComponent extends StatelessWidget { +// final QuestionAnswerItem item; - const QuizItemComponent({super.key, required this.item}); +// const QuizItemComponent({super.key, required this.item}); - @override - Widget build(BuildContext context) { - final bool isOptionType = item.type == 'option'; +// @override +// Widget build(BuildContext context) { +// final bool isOptionType = item.type == 'option'; - return Container( - width: double.infinity, - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.04), - blurRadius: 6, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildQuestionText(), - const SizedBox(height: 16), - if (isOptionType && item.options != null) _buildOptions(), - const SizedBox(height: 12), - _buildAnswerIndicator(), - const SizedBox(height: 16), - const Divider(height: 24, color: AppColors.shadowPrimary), - _buildMetadata(), - ], - ), - ); - } +// return Container( +// width: double.infinity, +// margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), +// padding: const EdgeInsets.all(20), +// decoration: BoxDecoration( +// color: Colors.white, +// borderRadius: BorderRadius.circular(16), +// boxShadow: [ +// BoxShadow( +// color: Colors.black.withValues(alpha: 0.04), +// blurRadius: 6, +// offset: const Offset(0, 2), +// ), +// ], +// ), +// child: Column( +// crossAxisAlignment: CrossAxisAlignment.start, +// children: [ +// _buildQuestionText(), +// const SizedBox(height: 16), +// if (isOptionType && item.options != null) _buildOptions(), +// const SizedBox(height: 12), +// _buildAnswerIndicator(), +// const SizedBox(height: 16), +// const Divider(height: 24, color: AppColors.shadowPrimary), +// _buildMetadata(), +// ], +// ), +// ); +// } - Widget _buildQuestionText() { - return Text( - '${item.index}. ${item.question}', - style: AppTextStyles.title.copyWith(fontSize: 16, fontWeight: FontWeight.w600), - ); - } +// Widget _buildQuestionText() { +// return Text( +// '${item.index}. ${item.question}', +// style: AppTextStyles.title.copyWith(fontSize: 16, fontWeight: FontWeight.w600), +// ); +// } - Widget _buildOptions() { - return Column( - children: item.options!.asMap().entries.map((entry) { - final int index = entry.key; - final String text = entry.value; +// Widget _buildOptions() { +// return Column( +// children: item.options!.asMap().entries.map((entry) { +// final int index = entry.key; +// final String text = entry.value; - final bool isCorrectAnswer = index == item.targetAnswer; - final bool isUserWrongAnswer = index == item.userAnswer && !isCorrectAnswer; +// final bool isCorrectAnswer = index == item.targetAnswer; +// final bool isUserWrongAnswer = index == item.userAnswer && !isCorrectAnswer; - Color? backgroundColor; - IconData icon = LucideIcons.circle; - Color iconColor = AppColors.shadowPrimary; +// Color? backgroundColor; +// IconData icon = LucideIcons.circle; +// Color iconColor = AppColors.shadowPrimary; - if (isCorrectAnswer) { - backgroundColor = AppColors.primaryBlue.withValues(alpha: 0.15); - icon = LucideIcons.checkCircle2; - iconColor = AppColors.primaryBlue; - } else if (isUserWrongAnswer) { - backgroundColor = Colors.red.withValues(alpha: 0.15); - icon = LucideIcons.xCircle; - iconColor = Colors.red; - } +// if (isCorrectAnswer) { +// backgroundColor = AppColors.primaryBlue.withValues(alpha: 0.15); +// icon = LucideIcons.checkCircle2; +// iconColor = AppColors.primaryBlue; +// } else if (isUserWrongAnswer) { +// backgroundColor = Colors.red.withValues(alpha: 0.15); +// icon = LucideIcons.xCircle; +// iconColor = Colors.red; +// } - return Container( - width: double.infinity, - margin: const EdgeInsets.symmetric(vertical: 6), - padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.shadowPrimary), - ), - child: Row( - children: [ - Icon(icon, size: 16, color: iconColor), - const SizedBox(width: 8), - Flexible( - child: Text(text, style: AppTextStyles.optionText), - ), - ], - ), - ); - }).toList(), - ); - } +// return Container( +// width: double.infinity, +// margin: const EdgeInsets.symmetric(vertical: 6), +// padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), +// decoration: BoxDecoration( +// color: backgroundColor, +// borderRadius: BorderRadius.circular(12), +// border: Border.all(color: AppColors.shadowPrimary), +// ), +// child: Row( +// children: [ +// Icon(icon, size: 16, color: iconColor), +// const SizedBox(width: 8), +// Flexible( +// child: Text(text, style: AppTextStyles.optionText), +// ), +// ], +// ), +// ); +// }).toList(), +// ); +// } - Widget _buildAnswerIndicator() { - final correctIcon = item.isCorrect ? LucideIcons.checkCircle2 : LucideIcons.xCircle; - final correctColor = item.isCorrect ? AppColors.primaryBlue : Colors.red; +// Widget _buildAnswerIndicator() { +// final correctIcon = item.isCorrect ? LucideIcons.checkCircle2 : LucideIcons.xCircle; +// final correctColor = item.isCorrect ? AppColors.primaryBlue : Colors.red; - final String userAnswerText = item.type == 'option' ? item.options![item.userAnswer] : item.userAnswer.toString(); +// final String userAnswerText = item.type == 'option' ? item.options![item.userAnswer] : item.userAnswer.toString(); - final String correctAnswerText = item.targetAnswer.toString(); +// final String correctAnswerText = item.targetAnswer.toString(); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(correctIcon, color: correctColor, size: 18), - const SizedBox(width: 8), - Text( - 'Jawabanmu: $userAnswerText', - style: AppTextStyles.statValue, - ), - ], - ), - if (item.type != 'option' && !item.isCorrect) ...[ - const SizedBox(height: 6), - Row( - children: [ - const SizedBox(width: 26), // offset for icon + spacing - Text( - 'Jawaban benar: $correctAnswerText', - style: AppTextStyles.caption, - ), - ], - ), - ], - ], - ); - } +// return Column( +// crossAxisAlignment: CrossAxisAlignment.start, +// children: [ +// Row( +// children: [ +// Icon(correctIcon, color: correctColor, size: 18), +// const SizedBox(width: 8), +// Text( +// 'Jawabanmu: $userAnswerText', +// style: AppTextStyles.statValue, +// ), +// ], +// ), +// if (item.type != 'option' && !item.isCorrect) ...[ +// const SizedBox(height: 6), +// Row( +// children: [ +// const SizedBox(width: 26), // offset for icon + spacing +// Text( +// 'Jawaban benar: $correctAnswerText', +// style: AppTextStyles.caption, +// ), +// ], +// ), +// ], +// ], +// ); +// } - Widget _buildMetadata() { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _metaItem(icon: LucideIcons.helpCircle, label: item.type), - _metaItem(icon: LucideIcons.clock3, label: '${item.timeSpent}s'), - ], - ); - } +// Widget _buildMetadata() { +// return Row( +// mainAxisAlignment: MainAxisAlignment.spaceBetween, +// children: [ +// _metaItem(icon: LucideIcons.helpCircle, label: item.type), +// _metaItem(icon: LucideIcons.clock3, label: '${item.timeSpent}s'), +// ], +// ); +// } - Widget _metaItem({required IconData icon, required String label}) { - return Row( - children: [ - Icon(icon, size: 16, color: AppColors.primaryBlue), - const SizedBox(width: 6), - Text(label, style: AppTextStyles.caption), - ], - ); - } -} +// Widget _metaItem({required IconData icon, required String label}) { +// return Row( +// children: [ +// Icon(icon, size: 16, color: AppColors.primaryBlue), +// const SizedBox(width: 6), +// Text(label, style: AppTextStyles.caption), +// ], +// ); +// } +// } diff --git a/lib/feature/history/view/detail_history_view.dart b/lib/feature/history/view/detail_history_view.dart index eba49d4..5b1b217 100644 --- a/lib/feature/history/view/detail_history_view.dart +++ b/lib/feature/history/view/detail_history_view.dart @@ -4,8 +4,8 @@ import 'package:lucide_icons/lucide_icons.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; import 'package:quiz_app/app/const/text/text_style.dart'; import 'package:quiz_app/component/widget/loading_widget.dart'; +import 'package:quiz_app/component/widget/quiz_item_wa_component.dart'; import 'package:quiz_app/feature/history/controller/detail_history_controller.dart'; -import 'package:quiz_app/feature/history/view/component/quiz_item_component.dart'; class DetailHistoryView extends GetView { const DetailHistoryView({super.key}); @@ -41,7 +41,18 @@ class DetailHistoryView extends GetView { } List quizListings() { - return controller.quizAnswer.questionListings.map((e) => QuizItemComponent(item: e)).toList(); + return controller.quizAnswer.questionListings + .map((e) => QuizItemWAComponent( + index: e.index, + isCorrect: e.isCorrect, + question: e.question, + targetAnswer: e.targetAnswer, + timeSpent: e.timeSpent, + type: e.type, + userAnswer: e.userAnswer, + options: e.options, + )) + .toList(); } Widget quizMetaInfo() { diff --git a/lib/feature/quiz_result/view/quiz_result_view.dart b/lib/feature/quiz_result/view/quiz_result_view.dart index 5de6962..63ca944 100644 --- a/lib/feature/quiz_result/view/quiz_result_view.dart +++ b/lib/feature/quiz_result/view/quiz_result_view.dart @@ -6,7 +6,7 @@ import 'package:quiz_app/data/models/quiz/question/fill_in_the_blank_question_mo import 'package:quiz_app/data/models/quiz/question/option_question_model.dart'; import 'package:quiz_app/data/models/quiz/question/true_false_question_model.dart'; import 'package:quiz_app/feature/quiz_result/controller/quiz_result_controller.dart'; -import 'package:quiz_app/feature/quiz_result/view/component/quiz_item_wa_component.dart'; +import 'package:quiz_app/component/widget/quiz_item_wa_component.dart'; class QuizResultView extends GetView { const QuizResultView({super.key}); From 80e6704becc80a2b2a31f206a48f129f6b960aa4 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Mon, 5 May 2025 00:09:53 +0700 Subject: [PATCH 045/104] fix: limitation on the quiz creation --- lib/core/endpoint/api_endpoint.dart | 2 + lib/core/utils/custom_notification.dart | 63 ++++++++ .../models/quiz/question_create_request.dart | 3 + lib/data/models/subject/subject_model.dart | 31 ++++ lib/data/services/subject_service.dart | 39 +++++ .../binding/quiz_preview_binding.dart | 8 +- .../controller/quiz_preview_controller.dart | 51 ++++++- .../component/subject_dropdown_component.dart | 52 +++++++ .../quiz_preview/view/quiz_preview.dart | 142 +++++++++--------- 9 files changed, 318 insertions(+), 73 deletions(-) create mode 100644 lib/core/utils/custom_notification.dart create mode 100644 lib/data/models/subject/subject_model.dart create mode 100644 lib/data/services/subject_service.dart create mode 100644 lib/feature/quiz_preview/view/component/subject_dropdown_component.dart diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart index b76756a..7b66832 100644 --- a/lib/core/endpoint/api_endpoint.dart +++ b/lib/core/endpoint/api_endpoint.dart @@ -13,4 +13,6 @@ class APIEndpoint { static const String historyQuiz = "/history"; static const String detailHistoryQuiz = "/history/detail"; + + static const String subject = "/subject"; } diff --git a/lib/core/utils/custom_notification.dart b/lib/core/utils/custom_notification.dart new file mode 100644 index 0000000..f69c5ca --- /dev/null +++ b/lib/core/utils/custom_notification.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class CustomNotification { + static void _showSnackbar({ + required String title, + required String message, + required IconData icon, + required Color backgroundColor, + Color textColor = Colors.white, + Color iconColor = Colors.white, + }) { + Get.snackbar( + title, + message, + icon: Icon(icon, color: iconColor), + backgroundColor: backgroundColor, + colorText: textColor, + borderRadius: 12, + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + snackPosition: SnackPosition.TOP, + duration: const Duration(seconds: 3), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + isDismissible: true, + forwardAnimationCurve: Curves.easeOutBack, + reverseAnimationCurve: Curves.easeInBack, + boxShadows: [ + BoxShadow( + color: Colors.black26, + blurRadius: 10, + offset: Offset(0, 4), + ), + ], + ); + } + + static void success({required String title, required String message}) { + _showSnackbar( + title: title, + message: message, + icon: Icons.check_circle_outline, + backgroundColor: Colors.green.shade600, + ); + } + + static void error({required String title, required String message}) { + _showSnackbar( + title: title, + message: message, + icon: Icons.error_outline, + backgroundColor: Colors.red.shade600, + ); + } + + static void warning({required String title, required String message}) { + _showSnackbar( + title: title, + message: message, + icon: Icons.warning_amber_rounded, + backgroundColor: Colors.orange.shade700, + ); + } +} diff --git a/lib/data/models/quiz/question_create_request.dart b/lib/data/models/quiz/question_create_request.dart index d5d0e34..a6466ba 100644 --- a/lib/data/models/quiz/question_create_request.dart +++ b/lib/data/models/quiz/question_create_request.dart @@ -8,6 +8,7 @@ class QuizCreateRequestModel { final int totalQuiz; final int limitDuration; final String authorId; + final String subjectId; final List questionListings; QuizCreateRequestModel({ @@ -18,6 +19,7 @@ class QuizCreateRequestModel { required this.totalQuiz, required this.limitDuration, required this.authorId, + required this.subjectId, required this.questionListings, }); @@ -30,6 +32,7 @@ class QuizCreateRequestModel { 'total_quiz': totalQuiz, 'limit_duration': limitDuration, 'author_id': authorId, + "subject_id": subjectId, 'question_listings': questionListings.map((e) => e.toJson()).toList(), }; } diff --git a/lib/data/models/subject/subject_model.dart b/lib/data/models/subject/subject_model.dart new file mode 100644 index 0000000..ddc5755 --- /dev/null +++ b/lib/data/models/subject/subject_model.dart @@ -0,0 +1,31 @@ +class SubjectModel { + final String id; + final String name; + final String alias; + final String description; + + SubjectModel({ + required this.id, + required this.name, + required this.alias, + required this.description, + }); + + factory SubjectModel.fromJson(Map json) { + return SubjectModel( + id: json['id'], + name: json['name'], + alias: json['alias'], + description: json['description'], + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'alias': alias, + 'description': description, + }; + } +} diff --git a/lib/data/services/subject_service.dart b/lib/data/services/subject_service.dart new file mode 100644 index 0000000..6cd95ef --- /dev/null +++ b/lib/data/services/subject_service.dart @@ -0,0 +1,39 @@ +import 'package:dio/dio.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/core/endpoint/api_endpoint.dart'; +import 'package:quiz_app/core/utils/logger.dart'; +import 'package:quiz_app/data/models/base/base_model.dart'; +import 'package:quiz_app/data/models/subject/subject_model.dart'; +import 'package:quiz_app/data/providers/dio_client.dart'; + +class SubjectService extends GetxService { + late final Dio _dio; + + @override + void onInit() { + _dio = Get.find().dio; + super.onInit(); + } + + Future>?> getSubject() async { + try { + final response = await _dio.get( + APIEndpoint.subject, + ); + + if (response.statusCode == 200) { + final parsedResponse = BaseResponseModel>.fromJson( + response.data, + (data) => (data as List).map((e) => SubjectModel.fromJson(e as Map)).toList(), + ); + + return parsedResponse; + } else { + return null; + } + } catch (e) { + logC.e("Quiz creation error: $e"); + return null; + } + } +} diff --git a/lib/feature/quiz_preview/binding/quiz_preview_binding.dart b/lib/feature/quiz_preview/binding/quiz_preview_binding.dart index 7d3c4d7..efbc228 100644 --- a/lib/feature/quiz_preview/binding/quiz_preview_binding.dart +++ b/lib/feature/quiz_preview/binding/quiz_preview_binding.dart @@ -1,12 +1,18 @@ import 'package:get/get.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/services/quiz_service.dart'; +import 'package:quiz_app/data/services/subject_service.dart'; import 'package:quiz_app/feature/quiz_preview/controller/quiz_preview_controller.dart'; class QuizPreviewBinding extends Bindings { @override void dependencies() { Get.lazyPut(() => QuizService()); - Get.lazyPut(() => QuizPreviewController(Get.find(), Get.find())); + Get.lazyPut(() => SubjectService()); + Get.lazyPut(() => QuizPreviewController( + Get.find(), + Get.find(), + Get.find(), + )); } } diff --git a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart index 66d0da7..c555aef 100644 --- a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart +++ b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart @@ -2,12 +2,16 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/const/enums/question_type.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/core/utils/custom_notification.dart'; import 'package:quiz_app/core/utils/logger.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/models/base/base_model.dart'; import 'package:quiz_app/data/models/quiz/question_create_request.dart'; import 'package:quiz_app/data/models/quiz/question_listings_model.dart'; import 'package:quiz_app/data/models/quiz/quiestion_data_model.dart'; +import 'package:quiz_app/data/models/subject/subject_model.dart'; import 'package:quiz_app/data/services/quiz_service.dart'; +import 'package:quiz_app/data/services/subject_service.dart'; class QuizPreviewController extends GetxController { final TextEditingController titleController = TextEditingController(); @@ -15,17 +19,29 @@ class QuizPreviewController extends GetxController { final QuizService _quizService; final UserController _userController; + final SubjectService _subjectService; - QuizPreviewController(this._quizService, this._userController); + QuizPreviewController( + this._quizService, + this._userController, + this._subjectService, + ); RxBool isPublic = false.obs; late final List data; + RxList subjects = [].obs; + + RxInt subjectIndex = 0.obs; + + String subjectId = ""; + @override void onInit() { super.onInit(); loadData(); + loadSubjectData(); } void loadData() { @@ -37,12 +53,31 @@ class QuizPreviewController extends GetxController { } } + void loadSubjectData() async { + BaseResponseModel>? respnse = await _subjectService.getSubject(); + if (respnse != null) { + subjects.assignAll(respnse.data!); + subjectId = subjects[0].id; + } + } + Future onSaveQuiz() async { final title = titleController.text.trim(); final description = descriptionController.text.trim(); if (title.isEmpty || description.isEmpty) { - Get.snackbar('Error', 'Judul dan deskripsi tidak boleh kosong!'); + CustomNotification.error( + title: 'Error', + message: 'Judul dan deskripsi tidak boleh kosong!', + ); + return; + } + + if (data.length < 10) { + CustomNotification.error( + title: 'Error', + message: 'Jumlah soal harus 10 atau lebih', + ); return; } @@ -58,12 +93,17 @@ class QuizPreviewController extends GetxController { totalQuiz: data.length, limitDuration: data.length * 30, authorId: _userController.userData!.id, + subjectId: subjectId, questionListings: _mapQuestionsToListings(data), ); final success = await _quizService.createQuiz(quizRequest); if (success) { - Get.snackbar('Sukses', 'Kuis berhasil disimpan!'); + CustomNotification.success( + title: 'Sukses', + message: 'Kuis berhasil disimpan!', + ); + Get.offAllNamed(AppRoutes.mainPage, arguments: 2); } } catch (e) { @@ -110,6 +150,11 @@ class QuizPreviewController extends GetxController { }).toList(); } + void onSubjectTap(String id, int index) { + subjectId = id; + subjectIndex.value = index; + } + @override void onClose() { titleController.dispose(); diff --git a/lib/feature/quiz_preview/view/component/subject_dropdown_component.dart b/lib/feature/quiz_preview/view/component/subject_dropdown_component.dart new file mode 100644 index 0000000..8e982f9 --- /dev/null +++ b/lib/feature/quiz_preview/view/component/subject_dropdown_component.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/data/models/subject/subject_model.dart'; + +class SubjectDropdownComponent extends StatelessWidget { + final List data; + final void Function(String id, int index) onItemTap; + final int selectedIndex; + + const SubjectDropdownComponent({ + super.key, + required this.data, + required this.onItemTap, + required this.selectedIndex, + }); + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + value: selectedIndex >= 0 && selectedIndex < data.length ? data[selectedIndex].id : null, + items: data.asMap().entries.map((entry) { + // final index = entry.key; + final subject = entry.value; + return DropdownMenuItem( + value: subject.id, + child: Text('${subject.alias} - ${subject.name}'), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + final index = data.indexWhere((e) => e.id == value); + if (index != -1) { + onItemTap(value, index); + } + } + }, + decoration: InputDecoration( + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: AppColors.borderLight), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: AppColors.borderLight), + ), + ), + ); + } +} diff --git a/lib/feature/quiz_preview/view/quiz_preview.dart b/lib/feature/quiz_preview/view/quiz_preview.dart index b50401c..8b5209c 100644 --- a/lib/feature/quiz_preview/view/quiz_preview.dart +++ b/lib/feature/quiz_preview/view/quiz_preview.dart @@ -6,6 +6,7 @@ import 'package:quiz_app/component/global_text_field.dart'; import 'package:quiz_app/component/label_text_field.dart'; import 'package:quiz_app/component/widget/question_container_widget.dart'; import 'package:quiz_app/feature/quiz_preview/controller/quiz_preview_controller.dart'; +import 'package:quiz_app/feature/quiz_preview/view/component/subject_dropdown_component.dart'; class QuizPreviewPage extends GetView { const QuizPreviewPage({super.key}); @@ -14,50 +15,65 @@ class QuizPreviewPage extends GetView { Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.background, - appBar: AppBar( - backgroundColor: AppColors.background, - elevation: 0, - title: const Text( - 'Preview Quiz', - style: TextStyle( - color: AppColors.darkText, - fontWeight: FontWeight.bold, - ), - ), - centerTitle: true, - iconTheme: const IconThemeData(color: AppColors.darkText), - ), + appBar: _buildAppBar(), body: SafeArea( child: Padding( padding: const EdgeInsets.all(20.0), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - LabelTextField(label: "Judul"), - GlobalTextField(controller: controller.titleController), - const SizedBox(height: 20), - LabelTextField(label: "Deskripsi Singkat"), - GlobalTextField(controller: controller.descriptionController), - const SizedBox(height: 20), - _buildPublicCheckbox(), // Ganti ke sini - const SizedBox(height: 30), - const Divider(thickness: 1.2, color: AppColors.borderLight), - const SizedBox(height: 20), - _buildQuestionContent(), - const SizedBox(height: 30), - GlobalButton( - onPressed: controller.onSaveQuiz, - text: "Simpan Kuis", - ), - ], - ), - ), + child: _buildContent(), ), ), ); } + PreferredSizeWidget _buildAppBar() { + return AppBar( + backgroundColor: AppColors.background, + elevation: 0, + centerTitle: true, + iconTheme: const IconThemeData(color: AppColors.darkText), + title: const Text( + 'Preview Quiz', + style: TextStyle( + color: AppColors.darkText, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + Widget _buildContent() { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const LabelTextField(label: "Judul"), + GlobalTextField(controller: controller.titleController), + const SizedBox(height: 20), + const LabelTextField(label: "Deskripsi Singkat"), + GlobalTextField(controller: controller.descriptionController), + const SizedBox(height: 20), + const LabelTextField(label: "Mata Pelajaran"), + Obx(() => SubjectDropdownComponent( + data: controller.subjects.toList(), + onItemTap: controller.onSubjectTap, + selectedIndex: controller.subjectIndex.value, + )), + const SizedBox(height: 20), + _buildPublicCheckbox(), + const SizedBox(height: 30), + const Divider(thickness: 1.2, color: AppColors.borderLight), + const SizedBox(height: 20), + _buildQuestionContent(), + const SizedBox(height: 30), + GlobalButton( + onPressed: controller.onSaveQuiz, + text: "Simpan Kuis", + ), + ], + ), + ); + } + Widget _buildQuestionContent() { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -68,40 +84,28 @@ class QuizPreviewPage extends GetView { } Widget _buildPublicCheckbox() { - return Obx( - () => GestureDetector( - onTap: () { - controller.isPublic.toggle(); - }, - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Checkbox( - value: controller.isPublic.value, - activeColor: AppColors.primaryBlue, // Pakai warna biru utama - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), + return Obx(() => GestureDetector( + onTap: controller.isPublic.toggle, + child: Row( + children: [ + Checkbox( + value: controller.isPublic.value, + activeColor: AppColors.primaryBlue, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + side: const BorderSide(color: AppColors.primaryBlue, width: 2), + onChanged: (val) => controller.isPublic.value = val ?? false, ), - side: const BorderSide( - color: AppColors.primaryBlue, // Pinggirannya juga biru - width: 2, + const SizedBox(width: 8), + const Text( + "Buat Kuis Public", + style: TextStyle( + fontSize: 16, + color: AppColors.darkText, + fontWeight: FontWeight.w500, + ), ), - onChanged: (value) { - controller.isPublic.value = value ?? false; - }, - ), - const SizedBox(width: 8), - const Text( - "Buat Kuis Public", - style: TextStyle( - fontSize: 16, - color: AppColors.darkText, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ); + ], + ), + )); } } From cd38b79bef3f92a5fabb9d2e8b70ece3a63714d3 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Mon, 5 May 2025 09:39:19 +0700 Subject: [PATCH 046/104] feat: finish implement subject on the home and search --- lib/feature/home/binding/home_binding.dart | 9 ++- .../home/controller/home_controller.dart | 26 ++++++- .../home/view/component/search_component.dart | 73 ++++++++++++------- lib/feature/home/view/home_page.dart | 5 +- .../controller/quiz_play_controller.dart | 1 - .../search/binding/search_binding.dart | 9 ++- .../search/controller/search_controller.dart | 20 ++++- lib/feature/search/view/search_view.dart | 35 ++++++--- 8 files changed, 136 insertions(+), 42 deletions(-) diff --git a/lib/feature/home/binding/home_binding.dart b/lib/feature/home/binding/home_binding.dart index 53a5d45..e1b47cc 100644 --- a/lib/feature/home/binding/home_binding.dart +++ b/lib/feature/home/binding/home_binding.dart @@ -1,11 +1,18 @@ import 'package:get/get.dart'; import 'package:quiz_app/data/services/quiz_service.dart'; +import 'package:quiz_app/data/services/subject_service.dart'; import 'package:quiz_app/feature/home/controller/home_controller.dart'; class HomeBinding extends Bindings { @override void dependencies() { Get.lazyPut(() => QuizService()); - Get.lazyPut(() => HomeController(Get.find())); + Get.lazyPut(() => SubjectService()); + Get.lazyPut( + () => HomeController( + Get.find(), + Get.find(), + ), + ); } } diff --git a/lib/feature/home/controller/home_controller.dart b/lib/feature/home/controller/home_controller.dart index c0b2d84..78afb4e 100644 --- a/lib/feature/home/controller/home_controller.dart +++ b/lib/feature/home/controller/home_controller.dart @@ -3,23 +3,40 @@ import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/models/base/base_model.dart'; import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; +import 'package:quiz_app/data/models/subject/subject_model.dart'; import 'package:quiz_app/data/services/quiz_service.dart'; +import 'package:quiz_app/data/services/subject_service.dart'; +import 'package:quiz_app/feature/navigation/controllers/navigation_controller.dart'; class HomeController extends GetxController { final UserController _userController = Get.find(); + final QuizService _quizService; - HomeController(this._quizService); + final SubjectService _subjectService; + + HomeController( + this._quizService, + this._subjectService, + ); Rx get userName => _userController.userName; Rx get userImage => _userController.userImage; RxList data = [].obs; + RxList subjects = [].obs; + void goToQuizCreation() => Get.toNamed(AppRoutes.quizCreatePage); + void goToSearch() { + final navController = Get.find(); + navController.changePage(1); + } + @override void onInit() { _getRecomendationQuiz(); + loadSubjectData(); super.onInit(); } @@ -30,6 +47,13 @@ class HomeController extends GetxController { } } + void loadSubjectData() async { + BaseResponseModel>? respnse = await _subjectService.getSubject(); + if (respnse != null) { + subjects.assignAll(respnse.data!); + } + } + void onRecommendationTap(String quizId) => Get.toNamed(AppRoutes.detailQuizPage, arguments: quizId); void goToListingsQuizPage() => Get.toNamed(AppRoutes.listingQuizPage); diff --git a/lib/feature/home/view/component/search_component.dart b/lib/feature/home/view/component/search_component.dart index 512b314..129d733 100644 --- a/lib/feature/home/view/component/search_component.dart +++ b/lib/feature/home/view/component/search_component.dart @@ -1,8 +1,16 @@ import 'package:flutter/material.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/data/models/subject/subject_model.dart'; class SearchComponent extends StatelessWidget { - const SearchComponent({super.key}); + final Function() onSearchTap; + final List subject; + + const SearchComponent({ + super.key, + required this.onSearchTap, + required this.subject, + }); @override Widget build(BuildContext context) { @@ -52,12 +60,18 @@ class SearchComponent extends StatelessWidget { } Widget _buildCategoryRow() { - return Row( - children: [ - _buildCategoryComponent("History"), - const SizedBox(width: 8), - _buildCategoryComponent("Science"), - ], + return SizedBox( + height: 30, // Set height for horizontal ListView + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: subject.length, + itemBuilder: (context, index) { + return Padding( + padding: EdgeInsets.only(right: index != subject.length - 1 ? 8.0 : 0), + child: _buildCategoryComponent(subject[index].alias), + ); + }, + ), ); } @@ -80,24 +94,33 @@ class SearchComponent extends StatelessWidget { } Widget _buildSearchInput() { - return Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 6, - offset: Offset(0, 2), - ), - ], - ), - child: const TextField( - decoration: InputDecoration( - hintText: "Search for quizzes...", - hintStyle: TextStyle(color: Color(0xFF6B778C)), - border: InputBorder.none, - contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + return GestureDetector( + onTap: () => onSearchTap(), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: const [ + Icon(Icons.search, color: Color(0xFF6B778C)), + SizedBox(width: 8), + Text( + "Search for quizzes...", + style: TextStyle( + color: Color(0xFF6B778C), + fontSize: 16, + ), + ), + ], ), ), ); diff --git a/lib/feature/home/view/home_page.dart b/lib/feature/home/view/home_page.dart index 7f8b84a..f220ed5 100644 --- a/lib/feature/home/view/home_page.dart +++ b/lib/feature/home/view/home_page.dart @@ -41,7 +41,10 @@ class HomeView extends GetView { padding: const EdgeInsets.all(20), child: Column( children: [ - SearchComponent(), + Obx(() => SearchComponent( + onSearchTap: controller.goToSearch, + subject: controller.subjects.toList(), + )), const SizedBox(height: 20), Obx( () => RecomendationComponent( diff --git a/lib/feature/quiz_play/controller/quiz_play_controller.dart b/lib/feature/quiz_play/controller/quiz_play_controller.dart index b1969ec..a8f02ee 100644 --- a/lib/feature/quiz_play/controller/quiz_play_controller.dart +++ b/lib/feature/quiz_play/controller/quiz_play_controller.dart @@ -154,7 +154,6 @@ class QuizPlayController extends GetxController { await Future.delayed(Duration(seconds: 2)); - print(quizData); Get.offAllNamed( AppRoutes.resultQuizPage, diff --git a/lib/feature/search/binding/search_binding.dart b/lib/feature/search/binding/search_binding.dart index 2d2c0f4..3383d06 100644 --- a/lib/feature/search/binding/search_binding.dart +++ b/lib/feature/search/binding/search_binding.dart @@ -1,5 +1,6 @@ import 'package:get/get.dart'; import 'package:quiz_app/data/services/quiz_service.dart'; +import 'package:quiz_app/data/services/subject_service.dart'; import 'package:quiz_app/feature/search/controller/search_controller.dart'; class SearchBinding extends Bindings { @@ -8,6 +9,12 @@ class SearchBinding extends Bindings { if (!Get.isRegistered()) { Get.lazyPut(() => QuizService()); } - Get.lazyPut(() => SearchQuizController(Get.find())); + + if (!Get.isRegistered()) Get.lazyPut(() => SubjectService()); + + Get.lazyPut(() => SearchQuizController( + Get.find(), + Get.find(), + )); } } diff --git a/lib/feature/search/controller/search_controller.dart b/lib/feature/search/controller/search_controller.dart index f8c932c..f03fef4 100644 --- a/lib/feature/search/controller/search_controller.dart +++ b/lib/feature/search/controller/search_controller.dart @@ -3,12 +3,18 @@ import 'package:get/get.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/data/models/base/base_model.dart'; import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; +import 'package:quiz_app/data/models/subject/subject_model.dart'; import 'package:quiz_app/data/services/quiz_service.dart'; +import 'package:quiz_app/data/services/subject_service.dart'; class SearchQuizController extends GetxController { final QuizService _quizService; + final SubjectService _subjectService; - SearchQuizController(this._quizService); + SearchQuizController( + this._quizService, + this._subjectService, + ); final searchController = TextEditingController(); final searchText = ''.obs; @@ -16,9 +22,12 @@ class SearchQuizController extends GetxController { RxList recommendationQData = [].obs; RxList searchQData = [].obs; + RxList subjects = [].obs; + @override void onInit() { getRecomendation(); + loadSubjectData(); super.onInit(); searchController.addListener(() { searchText.value = searchController.text; @@ -49,6 +58,15 @@ class SearchQuizController extends GetxController { void goToListingsQuizPage() => Get.toNamed(AppRoutes.listingQuizPage); + void loadSubjectData() async { + BaseResponseModel>? respnse = await _subjectService.getSubject(); + if (respnse != null) { + subjects.assignAll(respnse.data!); + } + } + + void goToDetailQuizListing() {} + @override void onClose() { searchController.dispose(); diff --git a/lib/feature/search/view/search_view.dart b/lib/feature/search/view/search_view.dart index a188618..51db20c 100644 --- a/lib/feature/search/view/search_view.dart +++ b/lib/feature/search/view/search_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/component/quiz_container_component.dart'; import 'package:quiz_app/component/widget/recomendation_component.dart'; +import 'package:quiz_app/data/models/subject/subject_model.dart'; import 'package:quiz_app/feature/search/controller/search_controller.dart'; class SearchView extends GetView { @@ -21,7 +22,7 @@ class SearchView extends GetView { _buildSearchBar(), const SizedBox(height: 20), if (isSearching) ...[ - _buildCategoryFilter(), + Obx(() => _buildCategoryFilter(controller.subjects.toList())), const SizedBox(height: 20), ...controller.searchQData.map( (e) => QuizContainerComponent(data: e, onTap: controller.goToDetailPage), @@ -78,17 +79,29 @@ class SearchView extends GetView { ); } - Widget _buildCategoryFilter() { - final categories = ['Fisika', 'Matematika', 'Agama', 'English', 'Sejarah', 'Biologi']; + Widget _buildCategoryFilter(List data) { return Wrap( - spacing: 8, - runSpacing: 8, - children: categories.map((cat) { - return Chip( - label: Text(cat), - padding: const EdgeInsets.symmetric(horizontal: 12), - backgroundColor: Colors.white, - side: const BorderSide(color: Colors.black12), + spacing: 6, + runSpacing: 1, + children: data.map((cat) { + return InkWell( + onTap: () => controller.goToDetailQuizListing, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + margin: const EdgeInsets.symmetric(vertical: 2), + decoration: BoxDecoration( + color: Color(0xFFD6E4FF), + borderRadius: BorderRadius.circular(15), + ), + child: Text( + cat.alias, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF0052CC), + ), + ), + ), ); }).toList(), ); From 93ab86e8335069164f1c22f8f78fe1019fd60405 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Mon, 5 May 2025 11:38:40 +0700 Subject: [PATCH 047/104] feat: done working on subject quiz listing --- lib/app/const/enums/listing_type.dart | 1 + lib/data/services/quiz_service.dart | 12 ++- .../home/controller/home_controller.dart | 6 +- .../home/view/component/search_component.dart | 11 ++- lib/feature/home/view/home_page.dart | 4 +- .../controller/listing_quiz_controller.dart | 81 ++++++++++++++++--- .../listing_quiz/view/listing_quiz_view.dart | 6 +- .../search/controller/search_controller.dart | 12 +-- lib/feature/search/view/search_view.dart | 7 +- 9 files changed, 111 insertions(+), 29 deletions(-) create mode 100644 lib/app/const/enums/listing_type.dart diff --git a/lib/app/const/enums/listing_type.dart b/lib/app/const/enums/listing_type.dart new file mode 100644 index 0000000..ac76db8 --- /dev/null +++ b/lib/app/const/enums/listing_type.dart @@ -0,0 +1 @@ +enum ListingType { recomendation, populer, subject } diff --git a/lib/data/services/quiz_service.dart b/lib/data/services/quiz_service.dart index 66e590e..b92358d 100644 --- a/lib/data/services/quiz_service.dart +++ b/lib/data/services/quiz_service.dart @@ -74,9 +74,17 @@ class QuizService extends GetxService { } } - Future>?> searchQuiz(String keyword) async { + Future>?> searchQuiz(String keyword, int page, {int limit = 10, String? subjectId}) async { try { - final response = await _dio.get("${APIEndpoint.quizSearch}?keyword=$keyword&page=1&limit=10"); + final queryParams = { + "keyword": keyword, + "page": page.toString(), + "limit": limit.toString(), + if (subjectId != null && subjectId.isNotEmpty) "subject_id": subjectId, + }; + + final uri = Uri.parse(APIEndpoint.quizSearch).replace(queryParameters: queryParams); + final response = await _dio.getUri(uri); if (response.statusCode == 200) { final parsedResponse = BaseResponseModel>.fromJson( diff --git a/lib/feature/home/controller/home_controller.dart b/lib/feature/home/controller/home_controller.dart index 78afb4e..8312079 100644 --- a/lib/feature/home/controller/home_controller.dart +++ b/lib/feature/home/controller/home_controller.dart @@ -1,4 +1,5 @@ import 'package:get/get.dart'; +import 'package:quiz_app/app/const/enums/listing_type.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/models/base/base_model.dart'; @@ -56,5 +57,8 @@ class HomeController extends GetxController { void onRecommendationTap(String quizId) => Get.toNamed(AppRoutes.detailQuizPage, arguments: quizId); - void goToListingsQuizPage() => Get.toNamed(AppRoutes.listingQuizPage); + void goToListingsQuizPage(ListingType page, {String? subjectId, String? subjecName}) => Get.toNamed( + AppRoutes.listingQuizPage, + arguments: {"page": page, "id": subjectId, "subject_name": subjecName}, + ); } diff --git a/lib/feature/home/view/component/search_component.dart b/lib/feature/home/view/component/search_component.dart index 129d733..c02235e 100644 --- a/lib/feature/home/view/component/search_component.dart +++ b/lib/feature/home/view/component/search_component.dart @@ -4,10 +4,12 @@ import 'package:quiz_app/data/models/subject/subject_model.dart'; class SearchComponent extends StatelessWidget { final Function() onSearchTap; + final Function(String, String) onSubjectTap; final List subject; const SearchComponent({ super.key, + required this.onSubjectTap, required this.onSearchTap, required this.subject, }); @@ -66,9 +68,12 @@ class SearchComponent extends StatelessWidget { scrollDirection: Axis.horizontal, itemCount: subject.length, itemBuilder: (context, index) { - return Padding( - padding: EdgeInsets.only(right: index != subject.length - 1 ? 8.0 : 0), - child: _buildCategoryComponent(subject[index].alias), + return GestureDetector( + onTap: () => onSubjectTap(subject[index].id, subject[index].alias), + child: Padding( + padding: EdgeInsets.only(right: index != subject.length - 1 ? 8.0 : 0), + child: _buildCategoryComponent(subject[index].alias), + ), ); }, ), diff --git a/lib/feature/home/view/home_page.dart b/lib/feature/home/view/home_page.dart index f220ed5..7cc6bd7 100644 --- a/lib/feature/home/view/home_page.dart +++ b/lib/feature/home/view/home_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/app/const/enums/listing_type.dart'; import 'package:quiz_app/feature/home/controller/home_controller.dart'; import 'package:quiz_app/feature/home/view/component/button_option.dart'; import 'package:quiz_app/component/widget/recomendation_component.dart'; @@ -44,6 +45,7 @@ class HomeView extends GetView { Obx(() => SearchComponent( onSearchTap: controller.goToSearch, subject: controller.subjects.toList(), + onSubjectTap: (p0, p1) => controller.goToListingsQuizPage(ListingType.subject, subjectId: p0, subjecName: p1), )), const SizedBox(height: 20), Obx( @@ -51,7 +53,7 @@ class HomeView extends GetView { title: "Quiz Rekomendasi", datas: controller.data.toList(), itemOnTap: controller.onRecommendationTap, - allOnTap: controller.goToListingsQuizPage, + allOnTap: () => controller.goToListingsQuizPage(ListingType.recomendation), ), ), ], diff --git a/lib/feature/listing_quiz/controller/listing_quiz_controller.dart b/lib/feature/listing_quiz/controller/listing_quiz_controller.dart index 6cf2b93..8a5db66 100644 --- a/lib/feature/listing_quiz/controller/listing_quiz_controller.dart +++ b/lib/feature/listing_quiz/controller/listing_quiz_controller.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/app/const/enums/listing_type.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/data/models/base/base_model.dart'; import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; @@ -20,13 +21,37 @@ class ListingQuizController extends GetxController { int currentPage = 1; bool hasMore = true; + RxString appBarTitle = "Quiz Recommendation".obs; + bool isSearchMode = false; + String? currentSubjectId; + @override void onInit() { super.onInit(); - _getRecomendationQuiz(); + loadData(); scrollController.addListener(_onScroll); } + void loadData() { + final Map data = Get.arguments as Map; + final pageType = data['page'] as ListingType; + + switch (pageType) { + case ListingType.populer: + appBarTitle.value = "Quiz Populer"; + _loadRecommendation(resetPage: true); + break; + case ListingType.recomendation: + appBarTitle.value = "Quiz Recommendation"; + _loadRecommendation(resetPage: true); + break; + case ListingType.subject: + appBarTitle.value = "Quiz ${data["subject_name"]}"; + _loadBySubject(subjectId: data["id"], resetPage: true); + break; + } + } + void _onScroll() { if (scrollController.position.pixels >= scrollController.position.maxScrollExtent - 200) { if (!isLoadingMore.value && hasMore) { @@ -35,28 +60,60 @@ class ListingQuizController extends GetxController { } } - Future _getRecomendationQuiz() async { + Future _loadRecommendation({bool resetPage = false}) async { + isSearchMode = false; + currentSubjectId = null; + if (resetPage) currentPage = 1; + isLoading.value = true; - currentPage = 1; - BaseResponseModel? response = await _quizService.recomendationQuiz(amount: amountQuiz); - if (response != null && response.data != null) { - final data = response.data as List; - quizzes.assignAll(data); - hasMore = data.length == amountQuiz; - } + + final response = await _quizService.recomendationQuiz(page: currentPage, amount: amountQuiz); + _handleResponse(response, resetPage: resetPage); + + isLoading.value = false; + } + + Future _loadBySubject({required String subjectId, bool resetPage = false}) async { + isSearchMode = true; + currentSubjectId = subjectId; + if (resetPage) currentPage = 1; + + isLoading.value = true; + + final response = await _quizService.searchQuiz("", currentPage, subjectId: subjectId); + _handleResponse(response, resetPage: resetPage); + isLoading.value = false; } Future loadMoreQuiz() async { + if (!hasMore) return; + isLoadingMore.value = true; currentPage++; - BaseResponseModel? response = await _quizService.recomendationQuiz(page: currentPage, amount: amountQuiz); + + BaseResponseModel? response; + if (isSearchMode && currentSubjectId != null) { + response = await _quizService.searchQuiz("", currentPage, subjectId: currentSubjectId!); + } else { + response = await _quizService.recomendationQuiz(page: currentPage, amount: amountQuiz); + } + + _handleResponse(response, resetPage: false); + + isLoadingMore.value = false; + } + + void _handleResponse(BaseResponseModel? response, {required bool resetPage}) { if (response != null && response.data != null) { final data = response.data as List; - quizzes.addAll(data); + if (resetPage) { + quizzes.assignAll(data); + } else { + quizzes.addAll(data); + } hasMore = data.length == amountQuiz; } - isLoadingMore.value = false; } void goToDetailQuiz(String quizId) => Get.toNamed(AppRoutes.detailQuizPage, arguments: quizId); diff --git a/lib/feature/listing_quiz/view/listing_quiz_view.dart b/lib/feature/listing_quiz/view/listing_quiz_view.dart index af386f3..270e402 100644 --- a/lib/feature/listing_quiz/view/listing_quiz_view.dart +++ b/lib/feature/listing_quiz/view/listing_quiz_view.dart @@ -13,8 +13,10 @@ class ListingsQuizView extends GetView { backgroundColor: AppColors.background, appBar: AppBar( centerTitle: true, - title: const Text( - 'Daftar Kuis', + title: Obx( + () => Text( + controller.appBarTitle.value, + ), ), ), body: Padding( diff --git a/lib/feature/search/controller/search_controller.dart b/lib/feature/search/controller/search_controller.dart index f03fef4..d71062a 100644 --- a/lib/feature/search/controller/search_controller.dart +++ b/lib/feature/search/controller/search_controller.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/app/const/enums/listing_type.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/data/models/base/base_model.dart'; import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; @@ -35,7 +36,7 @@ class SearchQuizController extends GetxController { debounce( searchText, (value) => getSearchData(value), - time: Duration(seconds: 2), + time: Duration(seconds: 1), ); } @@ -48,7 +49,7 @@ class SearchQuizController extends GetxController { void getSearchData(String keyword) async { searchQData.clear(); - BaseResponseModel? response = await _quizService.searchQuiz(keyword); + BaseResponseModel? response = await _quizService.searchQuiz(keyword, 1); if (response != null) { searchQData.assignAll(response.data); } @@ -56,7 +57,10 @@ class SearchQuizController extends GetxController { void goToDetailPage(String quizId) => Get.toNamed(AppRoutes.detailQuizPage, arguments: quizId); - void goToListingsQuizPage() => Get.toNamed(AppRoutes.listingQuizPage); + void goToListingsQuizPage(ListingType page, {String? subjectId, String? subjecName}) => Get.toNamed( + AppRoutes.listingQuizPage, + arguments: {"page": page, "id": subjectId, "subject_name": subjecName}, + ); void loadSubjectData() async { BaseResponseModel>? respnse = await _subjectService.getSubject(); @@ -65,8 +69,6 @@ class SearchQuizController extends GetxController { } } - void goToDetailQuizListing() {} - @override void onClose() { searchController.dispose(); diff --git a/lib/feature/search/view/search_view.dart b/lib/feature/search/view/search_view.dart index 51db20c..43a9521 100644 --- a/lib/feature/search/view/search_view.dart +++ b/lib/feature/search/view/search_view.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/app/const/enums/listing_type.dart'; import 'package:quiz_app/component/quiz_container_component.dart'; import 'package:quiz_app/component/widget/recomendation_component.dart'; import 'package:quiz_app/data/models/subject/subject_model.dart'; @@ -33,7 +34,7 @@ class SearchView extends GetView { title: "Quiz Rekomendasi", datas: controller.recommendationQData.toList(), itemOnTap: controller.goToDetailPage, - allOnTap: controller.goToListingsQuizPage, + allOnTap: () => controller.goToListingsQuizPage(ListingType.recomendation), ), ), const SizedBox(height: 30), @@ -42,7 +43,7 @@ class SearchView extends GetView { title: "Quiz Populer", datas: controller.recommendationQData.toList(), itemOnTap: controller.goToDetailPage, - allOnTap: controller.goToListingsQuizPage, + allOnTap: () => controller.goToListingsQuizPage(ListingType.populer), ), ), ], @@ -85,7 +86,7 @@ class SearchView extends GetView { runSpacing: 1, children: data.map((cat) { return InkWell( - onTap: () => controller.goToDetailQuizListing, + onTap: () => controller.goToListingsQuizPage(ListingType.subject, subjectId: cat.id, subjecName: cat.alias), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), margin: const EdgeInsets.symmetric(vertical: 2), From ca9e9cde7dbfc4700f83f40664f1f4bb44762751 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Mon, 5 May 2025 13:48:14 +0700 Subject: [PATCH 048/104] feat: quiz done --- lib/core/endpoint/api_endpoint.dart | 2 + lib/data/models/answer/answer_model.dart | 31 ++++++++++++++++ .../answer/quiz_answer_submition_model.dart | 37 +++++++++++++++++++ lib/data/models/quiz/library_quiz_model.dart | 4 ++ lib/data/services/answer_service.dart | 28 ++++++++++++++ .../quiz_play/binding/quiz_play_binding.dart | 8 +++- .../controller/quiz_play_controller.dart | 30 ++++++++++++++- 7 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 lib/data/models/answer/answer_model.dart create mode 100644 lib/data/models/answer/quiz_answer_submition_model.dart diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart index 7b66832..f010423 100644 --- a/lib/core/endpoint/api_endpoint.dart +++ b/lib/core/endpoint/api_endpoint.dart @@ -7,6 +7,8 @@ class APIEndpoint { static const String register = "/register"; static const String quiz = "/quiz"; + static const String quizAnswer = "/quiz/answer"; + static const String userQuiz = "/quiz/user"; static const String quizRecomendation = "/quiz/recomendation"; static const String quizSearch = "/quiz/search"; diff --git a/lib/data/models/answer/answer_model.dart b/lib/data/models/answer/answer_model.dart new file mode 100644 index 0000000..10c2bd6 --- /dev/null +++ b/lib/data/models/answer/answer_model.dart @@ -0,0 +1,31 @@ +class AnswerModel { + final int questionIndex; + final dynamic answer; // String, bool, atau int + final bool isCorrect; + final double timeSpent; + + AnswerModel({ + required this.questionIndex, + required this.answer, + required this.isCorrect, + required this.timeSpent, + }); + + factory AnswerModel.fromJson(Map json) { + return AnswerModel( + questionIndex: json['question_index'], + answer: json['answer'], + isCorrect: json['is_correct'], + timeSpent: (json['time_spent'] as num).toDouble(), + ); + } + + Map toJson() { + return { + 'question_index': questionIndex, + 'answer': answer, + 'is_correct': isCorrect, + 'time_spent': timeSpent, + }; + } +} diff --git a/lib/data/models/answer/quiz_answer_submition_model.dart b/lib/data/models/answer/quiz_answer_submition_model.dart new file mode 100644 index 0000000..980ed7d --- /dev/null +++ b/lib/data/models/answer/quiz_answer_submition_model.dart @@ -0,0 +1,37 @@ +import 'package:quiz_app/data/models/answer/answer_model.dart'; + +class QuizAnswerSubmissionModel { + final String sessionId; + final String quizId; + final String userId; + final DateTime answeredAt; + final List answers; + + QuizAnswerSubmissionModel({ + required this.sessionId, + required this.quizId, + required this.userId, + required this.answeredAt, + required this.answers, + }); + + factory QuizAnswerSubmissionModel.fromJson(Map json) { + return QuizAnswerSubmissionModel( + sessionId: json['session_id'], + quizId: json['quiz_id'], + userId: json['user_id'], + answeredAt: DateTime.parse(json['answered_at']), + answers: (json['answers'] as List).map((e) => AnswerModel.fromJson(e)).toList(), + ); + } + + Map toJson() { + return { + 'session_id': sessionId, + 'quiz_id': quizId, + 'user_id': userId, + 'answered_at': answeredAt.toIso8601String(), + 'answers': answers.map((e) => e.toJson()).toList(), + }; + } +} diff --git a/lib/data/models/quiz/library_quiz_model.dart b/lib/data/models/quiz/library_quiz_model.dart index cc8d7a8..1bae33d 100644 --- a/lib/data/models/quiz/library_quiz_model.dart +++ b/lib/data/models/quiz/library_quiz_model.dart @@ -1,6 +1,7 @@ import 'package:quiz_app/data/models/quiz/question/base_qustion_model.dart'; class QuizData { + final String id; final String authorId; final String subjectId; final String subjectName; @@ -14,6 +15,7 @@ class QuizData { final List questionListings; QuizData({ + required this.id, required this.authorId, required this.subjectId, required this.subjectName, @@ -29,6 +31,7 @@ class QuizData { factory QuizData.fromJson(Map json) { return QuizData( + id: json["id"], authorId: json['author_id'], subjectId: json['subject_id'], subjectName: json['subject_alias'], @@ -45,6 +48,7 @@ class QuizData { Map toJson() { return { + 'id': id, 'author_id': authorId, 'subject_id': subjectId, 'subject_alias': subjectName, diff --git a/lib/data/services/answer_service.dart b/lib/data/services/answer_service.dart index e69de29..e97a9b0 100644 --- a/lib/data/services/answer_service.dart +++ b/lib/data/services/answer_service.dart @@ -0,0 +1,28 @@ +import 'package:dio/dio.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/core/endpoint/api_endpoint.dart'; +import 'package:quiz_app/core/utils/logger.dart'; +import 'package:quiz_app/data/models/base/base_model.dart'; +import 'package:quiz_app/data/providers/dio_client.dart'; + +class AnswerService extends GetxService { + late final Dio _dio; + + @override + void onInit() { + _dio = Get.find().dio; + super.onInit(); + } + + Future submitQuizAnswers(Map payload) async { + try { + await _dio.post( + APIEndpoint.quizAnswer, + data: payload, + ); + } on DioException catch (e) { + logC.e('Gagal mengirim jawaban: ${e.response?.data['message'] ?? e.message}'); + return null; + } + } +} diff --git a/lib/feature/quiz_play/binding/quiz_play_binding.dart b/lib/feature/quiz_play/binding/quiz_play_binding.dart index 7b3017e..d58c70e 100644 --- a/lib/feature/quiz_play/binding/quiz_play_binding.dart +++ b/lib/feature/quiz_play/binding/quiz_play_binding.dart @@ -1,9 +1,15 @@ import 'package:get/get.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/services/answer_service.dart'; import 'package:quiz_app/feature/quiz_play/controller/quiz_play_controller.dart'; class QuizPlayBinding extends Bindings { @override void dependencies() { - Get.lazyPut(() => QuizPlayController()); + Get.lazyPut(() => AnswerService()); + Get.lazyPut(() => QuizPlayController( + Get.find(), + Get.find(), + )); } } diff --git a/lib/feature/quiz_play/controller/quiz_play_controller.dart b/lib/feature/quiz_play/controller/quiz_play_controller.dart index a8f02ee..57155d4 100644 --- a/lib/feature/quiz_play/controller/quiz_play_controller.dart +++ b/lib/feature/quiz_play/controller/quiz_play_controller.dart @@ -4,13 +4,21 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/component/notification/pop_up_confirmation.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/models/answer/answer_model.dart'; import 'package:quiz_app/data/models/quiz/library_quiz_model.dart'; import 'package:quiz_app/data/models/quiz/question/base_qustion_model.dart'; import 'package:quiz_app/data/models/quiz/question/fill_in_the_blank_question_model.dart'; import 'package:quiz_app/data/models/quiz/question/option_question_model.dart'; import 'package:quiz_app/data/models/quiz/question/true_false_question_model.dart'; +import 'package:quiz_app/data/services/answer_service.dart'; class QuizPlayController extends GetxController { + final AnswerService _answerService; + final UserController _userController; + + QuizPlayController(this._answerService, this._userController); + late final QuizData quizData; // State & UI @@ -152,8 +160,28 @@ class QuizPlayController extends GetxController { AppDialog.showMessage(Get.context!, "Yeay semua soal selesai"); - await Future.delayed(Duration(seconds: 2)); + // Kirim data ke server + try { + await _answerService.submitQuizAnswers({ + "session_id": "", // Sesuaikan logika session ID + "quiz_id": quizData.id, + "user_id": _userController.userData!.id, // Asumsikan ini ada di model `QuizData` + "answered_at": DateTime.now().toIso8601String(), + "answers": answeredQuestions.map((answered) { + return AnswerModel( + questionIndex: answered.questionIndex, + answer: answered.selectedAnswer, + isCorrect: answered.isCorrect, + timeSpent: answered.duration.toDouble(), + ); + }).toList(), + }); + } catch (e) { + AppDialog.showMessage(Get.context!, "Gagal mengirim jawaban: $e"); + return; + } + await Future.delayed(const Duration(seconds: 2)); Get.offAllNamed( AppRoutes.resultQuizPage, From 481bfbe2287a95d9cf97650ae2de49480c5362d3 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Mon, 5 May 2025 14:40:26 +0700 Subject: [PATCH 049/104] feat: done working on the room maker --- lib/app/routes/app_pages.dart | 7 + lib/app/routes/app_routes.dart | 2 + lib/component/global_text_field.dart | 23 ++- .../home/controller/home_controller.dart | 16 +- lib/feature/home/view/home_page.dart | 2 +- .../binding/room_maker_binding.dart | 9 + .../controller/room_maker_controller.dart | 61 ++++++ .../room_maker/view/room_maker_view.dart | 175 ++++++++++++++++++ 8 files changed, 277 insertions(+), 18 deletions(-) create mode 100644 lib/feature/room_maker/binding/room_maker_binding.dart create mode 100644 lib/feature/room_maker/controller/room_maker_controller.dart create mode 100644 lib/feature/room_maker/view/room_maker_view.dart diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index 18eb3b1..59eead7 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -25,6 +25,8 @@ import 'package:quiz_app/feature/quiz_result/binding/quiz_result_binding.dart'; import 'package:quiz_app/feature/quiz_result/view/quiz_result_view.dart'; import 'package:quiz_app/feature/register/binding/register_binding.dart'; import 'package:quiz_app/feature/register/view/register_page.dart'; +import 'package:quiz_app/feature/room_maker/binding/room_maker_binding.dart'; +import 'package:quiz_app/feature/room_maker/view/room_maker_view.dart'; import 'package:quiz_app/feature/search/binding/search_binding.dart'; import 'package:quiz_app/feature/splash_screen/presentation/splash_screen_page.dart'; @@ -99,6 +101,11 @@ class AppPages { name: AppRoutes.detailHistoryPage, page: () => DetailHistoryView(), binding: DetailHistoryBinding(), + ), + GetPage( + name: AppRoutes.roomPage, + page: () => RoomMakerView(), + binding: RoomMakerBinding(), ) ]; } diff --git a/lib/app/routes/app_routes.dart b/lib/app/routes/app_routes.dart index 24a779d..6fa2357 100644 --- a/lib/app/routes/app_routes.dart +++ b/lib/app/routes/app_routes.dart @@ -17,4 +17,6 @@ abstract class AppRoutes { static const resultQuizPage = "/quiz/result"; static const detailHistoryPage = "/history/detail"; + + static const roomPage = "/room/quiz"; } diff --git a/lib/component/global_text_field.dart b/lib/component/global_text_field.dart index ca8369d..73ffe19 100644 --- a/lib/component/global_text_field.dart +++ b/lib/component/global_text_field.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; class GlobalTextField extends StatelessWidget { final TextEditingController controller; @@ -8,22 +9,24 @@ class GlobalTextField extends StatelessWidget { final bool isPassword; final bool obscureText; final VoidCallback? onToggleVisibility; + final TextInputType textInputType; - const GlobalTextField({ - super.key, - required this.controller, - this.hintText, - this.labelText, - this.limitTextLine = 1, - this.isPassword = false, - this.obscureText = false, - this.onToggleVisibility, - }); + const GlobalTextField( + {super.key, + required this.controller, + this.hintText, + this.labelText, + this.limitTextLine = 1, + this.isPassword = false, + this.obscureText = false, + this.onToggleVisibility, + this.textInputType = TextInputType.text}); @override Widget build(BuildContext context) { return TextField( controller: controller, + keyboardType: textInputType, obscureText: isPassword ? obscureText : false, maxLines: limitTextLine, // <-- ini tambahan dari limitTextLine decoration: InputDecoration( diff --git a/lib/feature/home/controller/home_controller.dart b/lib/feature/home/controller/home_controller.dart index 8312079..4dff15d 100644 --- a/lib/feature/home/controller/home_controller.dart +++ b/lib/feature/home/controller/home_controller.dart @@ -27,13 +27,6 @@ class HomeController extends GetxController { RxList subjects = [].obs; - void goToQuizCreation() => Get.toNamed(AppRoutes.quizCreatePage); - - void goToSearch() { - final navController = Get.find(); - navController.changePage(1); - } - @override void onInit() { _getRecomendationQuiz(); @@ -55,6 +48,15 @@ class HomeController extends GetxController { } } + void goToQuizCreation() => Get.toNamed(AppRoutes.quizCreatePage); + + void goToRoomMaker() => Get.toNamed(AppRoutes.roomPage); + + void goToSearch() { + final navController = Get.find(); + navController.changePage(1); + } + void onRecommendationTap(String quizId) => Get.toNamed(AppRoutes.detailQuizPage, arguments: quizId); void goToListingsQuizPage(ListingType page, {String? subjectId, String? subjecName}) => Get.toNamed( diff --git a/lib/feature/home/view/home_page.dart b/lib/feature/home/view/home_page.dart index 7cc6bd7..2cbddb6 100644 --- a/lib/feature/home/view/home_page.dart +++ b/lib/feature/home/view/home_page.dart @@ -35,7 +35,7 @@ class HomeView extends GetView { // ButtonOption di luar Padding ButtonOption( onCreate: controller.goToQuizCreation, - onCreateRoom: () {}, + onCreateRoom: controller.goToRoomMaker, onJoinRoom: () {}, ), Padding( diff --git a/lib/feature/room_maker/binding/room_maker_binding.dart b/lib/feature/room_maker/binding/room_maker_binding.dart new file mode 100644 index 0000000..2e5157a --- /dev/null +++ b/lib/feature/room_maker/binding/room_maker_binding.dart @@ -0,0 +1,9 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/feature/room_maker/controller/room_maker_controller.dart'; + +class RoomMakerBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => RoomMakerController()); + } +} diff --git a/lib/feature/room_maker/controller/room_maker_controller.dart b/lib/feature/room_maker/controller/room_maker_controller.dart new file mode 100644 index 0000000..23d58ed --- /dev/null +++ b/lib/feature/room_maker/controller/room_maker_controller.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; + +class RoomMakerController extends GetxController { + final roomName = ''.obs; + final selectedQuiz = Rxn(); + + RxBool isOnwQuiz = true.obs; + + final TextEditingController nameTC = TextEditingController(); + final TextEditingController maxPlayerTC = TextEditingController(); + + final availableQuizzes = [ + QuizListingModel( + quizId: '1', + authorId: 'u1', + authorName: 'Admin', + title: 'Sejarah Indonesia', + description: 'Kuis tentang kerajaan dan sejarah nusantara.', + date: '2025-05-01', + totalQuiz: 10, + duration: 600, + ), + QuizListingModel( + quizId: '2', + authorId: 'u2', + authorName: 'Guru IPA', + title: 'Ilmu Pengetahuan Alam', + description: 'Kuis IPA untuk kelas 8.', + date: '2025-04-28', + totalQuiz: 15, + duration: 900, + ), + ].obs; + + void createRoom() { + if (roomName.value.trim().isEmpty || selectedQuiz.value == null) { + Get.snackbar("Gagal", "Nama room dan kuis harus dipilih."); + return; + } + + final quiz = selectedQuiz.value!; + print("Membuat room:"); + print("- Nama: ${roomName.value}"); + print("- Quiz: ${quiz.title}"); + print("- Durasi: ${quiz.duration} detik"); + print("- Jumlah Soal: ${quiz.totalQuiz}"); + } + + void onQuizSourceChange(bool base) { + isOnwQuiz.value = base; + } + + void onQuizChoosen(String quizId) { + final selected = availableQuizzes.firstWhere((e) => e.quizId == quizId); + selectedQuiz.value = selected; + } + + void onCreateRoom() {} +} diff --git a/lib/feature/room_maker/view/room_maker_view.dart b/lib/feature/room_maker/view/room_maker_view.dart new file mode 100644 index 0000000..cf0ba5d --- /dev/null +++ b/lib/feature/room_maker/view/room_maker_view.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/app/const/text/text_style.dart'; +import 'package:quiz_app/component/global_button.dart'; +import 'package:quiz_app/component/global_text_field.dart'; +import 'package:quiz_app/component/label_text_field.dart'; +import 'package:quiz_app/component/quiz_container_component.dart'; +import 'package:quiz_app/feature/room_maker/controller/room_maker_controller.dart'; + +class RoomMakerView extends GetView { + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar(title: Text("Buat Room Quiz")), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + LabelTextField( + label: "Room Name", + ), + GlobalTextField( + controller: controller.nameTC, + ), + SizedBox( + height: 10, + ), + LabelTextField(label: "Jumlah Maksimal Pemain"), + GlobalTextField( + controller: controller.maxPlayerTC, + textInputType: TextInputType.number, + ), + const SizedBox(height: 10), + quizMeta(), + SizedBox( + height: 10, + ), + _buildModeSelector(), + SizedBox( + height: 10, + ), + Expanded( + child: Container( + child: Obx(() => ListView.builder( + itemCount: controller.availableQuizzes.length, + itemBuilder: (context, index) { + return QuizContainerComponent( + data: controller.availableQuizzes[index], + onTap: controller.onQuizChoosen, + ); + })), + ), + ), + SizedBox( + height: 10, + ), + GlobalButton(text: "Buat Room", onPressed: controller.onCreateRoom) + ], + ), + ), + ); + } + + Widget quizMeta() { + return Obx(() { + final quiz = controller.selectedQuiz.value; + if (quiz == null) return SizedBox.shrink(); + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + margin: const EdgeInsets.only(top: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 8, + offset: Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Kuis yang Dipilih", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + const SizedBox(height: 12), + _buildMetaRow("Judul", quiz.title), + _buildMetaRow("Deskripsi", quiz.description), + _buildMetaRow("Jumlah Soal", quiz.totalQuiz.toString()), + _buildMetaRow("Durasi", "${quiz.duration ~/ 60} menit"), + ], + ), + ); + }); + } + + Widget _buildMetaRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("$label: ", style: AppTextStyles.subtitle), + Expanded( + child: Text( + value, + style: AppTextStyles.subtitle, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + Widget _buildModeSelector() { + return Container( + decoration: BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.borderLight), + ), + child: Row( + children: [ + _buildModeButton('kuismu', controller.isOnwQuiz, true), + _buildModeButton('Rekomendasi', controller.isOnwQuiz, false), + ], + ), + ); + } + + Widget _buildModeButton(String label, RxBool isSelected, bool base) { + return Expanded( + child: InkWell( + onTap: () => controller.onQuizSourceChange(base), + child: Obx( + () => Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: isSelected.value == base ? AppColors.primaryBlue : Colors.transparent, + borderRadius: base + ? BorderRadius.only( + topLeft: Radius.circular(10), + bottomLeft: Radius.circular(10), + ) + : BorderRadius.only( + topRight: Radius.circular(10), + bottomRight: Radius.circular(10), + ), + ), + alignment: Alignment.center, + child: Text( + label, + style: TextStyle( + color: isSelected.value == base ? Colors.white : AppColors.softGrayText, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ); + } +} From 49e33d00a918971f639af7cb69751915ab9399a1 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Wed, 7 May 2025 00:32:38 +0700 Subject: [PATCH 050/104] feat: waiting room --- lib/app/routes/app_pages.dart | 7 ++ lib/app/routes/app_routes.dart | 1 + lib/core/endpoint/api_endpoint.dart | 5 +- lib/data/dto/waiting_room_dto.dart | 8 ++ .../models/session/session_request_model.dart | 27 +++++ .../session/session_response_model.dart | 20 +++ lib/data/models/user/user_model.dart | 9 ++ lib/data/providers/dio_client.dart | 2 +- lib/data/services/answer_service.dart | 1 + lib/data/services/session_service.dart | 38 ++++++ lib/data/services/socket_service.dart | 87 +++++++++++++ .../binding/room_maker_binding.dart | 5 +- .../controller/room_maker_controller.dart | 34 ++++-- .../binding/waiting_room_binding.dart | 15 +++ .../controller/waiting_room_controller.dart | 69 +++++++++++ .../waiting_room/view/waiting_room_view.dart | 114 ++++++++++++++++++ pubspec.lock | 24 ++++ pubspec.yaml | 1 + 18 files changed, 454 insertions(+), 13 deletions(-) create mode 100644 lib/data/dto/waiting_room_dto.dart create mode 100644 lib/data/models/session/session_request_model.dart create mode 100644 lib/data/models/session/session_response_model.dart create mode 100644 lib/data/models/user/user_model.dart create mode 100644 lib/data/services/session_service.dart create mode 100644 lib/data/services/socket_service.dart create mode 100644 lib/feature/waiting_room/binding/waiting_room_binding.dart create mode 100644 lib/feature/waiting_room/controller/waiting_room_controller.dart create mode 100644 lib/feature/waiting_room/view/waiting_room_view.dart diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index 59eead7..e815171 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -29,6 +29,8 @@ import 'package:quiz_app/feature/room_maker/binding/room_maker_binding.dart'; import 'package:quiz_app/feature/room_maker/view/room_maker_view.dart'; import 'package:quiz_app/feature/search/binding/search_binding.dart'; import 'package:quiz_app/feature/splash_screen/presentation/splash_screen_page.dart'; +import 'package:quiz_app/feature/waiting_room/binding/waiting_room_binding.dart'; +import 'package:quiz_app/feature/waiting_room/view/waiting_room_view.dart'; part 'app_routes.dart'; @@ -106,6 +108,11 @@ class AppPages { name: AppRoutes.roomPage, page: () => RoomMakerView(), binding: RoomMakerBinding(), + ), + GetPage( + name: AppRoutes.waitRoomPage, + page: () => WaitingRoomView(), + binding: WaitingRoomBinding(), ) ]; } diff --git a/lib/app/routes/app_routes.dart b/lib/app/routes/app_routes.dart index 6fa2357..bdb3b2b 100644 --- a/lib/app/routes/app_routes.dart +++ b/lib/app/routes/app_routes.dart @@ -19,4 +19,5 @@ abstract class AppRoutes { static const detailHistoryPage = "/history/detail"; static const roomPage = "/room/quiz"; + static const waitRoomPage = "/room/quiz/waiting"; } diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart index f010423..4611a3d 100644 --- a/lib/core/endpoint/api_endpoint.dart +++ b/lib/core/endpoint/api_endpoint.dart @@ -1,5 +1,6 @@ class APIEndpoint { - static const String baseUrl = "http://192.168.1.9:5000/api"; + static const String baseUrl = "http://192.168.1.9:5000"; + static const String api = "$baseUrl/api"; static const String login = "/login"; static const String loginGoogle = "/login/google"; @@ -17,4 +18,6 @@ class APIEndpoint { static const String detailHistoryQuiz = "/history/detail"; static const String subject = "/subject"; + + static const String session = "/session"; } diff --git a/lib/data/dto/waiting_room_dto.dart b/lib/data/dto/waiting_room_dto.dart new file mode 100644 index 0000000..f72541f --- /dev/null +++ b/lib/data/dto/waiting_room_dto.dart @@ -0,0 +1,8 @@ +import 'package:quiz_app/data/models/session/session_response_model.dart'; + +class WaitingRoomDTO { + final bool isAdmin; + final SessionResponseModel data; + + WaitingRoomDTO(this.isAdmin, this.data); +} diff --git a/lib/data/models/session/session_request_model.dart b/lib/data/models/session/session_request_model.dart new file mode 100644 index 0000000..6eced2f --- /dev/null +++ b/lib/data/models/session/session_request_model.dart @@ -0,0 +1,27 @@ +class SessionRequestModel { + final String quizId; + final String hostId; + final int limitParticipan; + + SessionRequestModel({ + required this.quizId, + required this.hostId, + required this.limitParticipan, + }); + + factory SessionRequestModel.fromJson(Map json) { + return SessionRequestModel( + quizId: json['quiz_id'], + hostId: json['host_id'], + limitParticipan: json['limit_participan'], + ); + } + + Map toJson() { + return { + 'quiz_id': quizId, + 'host_id': hostId, + 'limit_participan': limitParticipan, + }; + } +} diff --git a/lib/data/models/session/session_response_model.dart b/lib/data/models/session/session_response_model.dart new file mode 100644 index 0000000..e9a58e3 --- /dev/null +++ b/lib/data/models/session/session_response_model.dart @@ -0,0 +1,20 @@ +class SessionResponseModel { + final String sessionId; + final String sessionCode; + + SessionResponseModel({required this.sessionId, required this.sessionCode}); + + factory SessionResponseModel.fromJson(Map json) { + return SessionResponseModel( + sessionId: json['session_id'], + sessionCode: json['session_code'], + ); + } + + Map toJson() { + return { + 'session_id': sessionId, + 'session_code': sessionCode, + }; + } +} diff --git a/lib/data/models/user/user_model.dart b/lib/data/models/user/user_model.dart new file mode 100644 index 0000000..eb21881 --- /dev/null +++ b/lib/data/models/user/user_model.dart @@ -0,0 +1,9 @@ +class UserModel { + final String id; + final String name; + + UserModel({ + required this.id, + required this.name, + }); +} diff --git a/lib/data/providers/dio_client.dart b/lib/data/providers/dio_client.dart index 11795fc..456597d 100644 --- a/lib/data/providers/dio_client.dart +++ b/lib/data/providers/dio_client.dart @@ -8,7 +8,7 @@ class ApiClient extends GetxService { Future init() async { dio = Dio(BaseOptions( - baseUrl: APIEndpoint.baseUrl, + baseUrl: APIEndpoint.api, connectTimeout: const Duration(minutes: 3), receiveTimeout: const Duration(minutes: 10), headers: { diff --git a/lib/data/services/answer_service.dart b/lib/data/services/answer_service.dart index e97a9b0..cc377cd 100644 --- a/lib/data/services/answer_service.dart +++ b/lib/data/services/answer_service.dart @@ -20,6 +20,7 @@ class AnswerService extends GetxService { APIEndpoint.quizAnswer, data: payload, ); + return BaseResponseModel(message: "success"); } on DioException catch (e) { logC.e('Gagal mengirim jawaban: ${e.response?.data['message'] ?? e.message}'); return null; diff --git a/lib/data/services/session_service.dart b/lib/data/services/session_service.dart new file mode 100644 index 0000000..5e19015 --- /dev/null +++ b/lib/data/services/session_service.dart @@ -0,0 +1,38 @@ +import 'package:dio/dio.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/core/endpoint/api_endpoint.dart'; +import 'package:quiz_app/data/models/base/base_model.dart'; +import 'package:quiz_app/data/models/session/session_request_model.dart'; +import 'package:quiz_app/data/models/session/session_response_model.dart'; +import 'package:quiz_app/data/providers/dio_client.dart'; + +class SessionService extends GetxService { + late final Dio _dio; + + @override + void onInit() { + _dio = Get.find().dio; + super.onInit(); + } + + Future?> createSession(SessionRequestModel data) async { + try { + final response = await _dio.post(APIEndpoint.session, data: { + 'quiz_id': data.quizId, + 'host_id': data.hostId, + 'limit_participan': data.limitParticipan, + }); + if (response.statusCode != 201) { + return null; + } + + return BaseResponseModel.fromJson(response.data, (e) => SessionResponseModel.fromJson(e)); + } on DioException catch (e) { + print('Error creating session: ${e.response?.data ?? e.message}'); + return null; + } catch (e) { + print('Unexpected error: $e'); + return null; + } + } +} diff --git a/lib/data/services/socket_service.dart b/lib/data/services/socket_service.dart new file mode 100644 index 0000000..4edd78c --- /dev/null +++ b/lib/data/services/socket_service.dart @@ -0,0 +1,87 @@ +import 'dart:async'; +import 'package:quiz_app/core/endpoint/api_endpoint.dart'; +import 'package:socket_io_client/socket_io_client.dart' as io; + +class SocketService { + late io.Socket socket; + + final _roomMessageController = StreamController>.broadcast(); + final _chatMessageController = StreamController>.broadcast(); + final _errorController = StreamController.broadcast(); + + Stream> get roomMessages => _roomMessageController.stream; + Stream> get chatMessages => _chatMessageController.stream; + Stream get errors => _errorController.stream; + + void initSocketConnection() { + socket = io.io( + APIEndpoint.baseUrl, + io.OptionBuilder() + .setTransports(['websocket']) // WebSocket mode + .disableAutoConnect() + .build(), + ); + + socket.connect(); + + socket.onConnect((_) { + print('Connected: ${socket.id}'); + }); + + socket.onDisconnect((_) { + print('Disconnected'); + }); + + socket.on('connection_response', (data) { + print('Connection response: $data'); + }); + + socket.on('room_message', (data) { + print('Room Message: $data'); + _roomMessageController.add(Map.from(data)); + }); + + socket.on('receive_message', (data) { + print('Message from ${data['from']}: ${data['message']}'); + _chatMessageController.add(Map.from(data)); + }); + + socket.on('error', (data) { + print('Socket error: $data'); + _errorController.add(data.toString()); + }); + } + + void joinRoom({required String sessionCode, required String userId}) { + socket.emit('join_room', { + 'session_code': sessionCode, + 'user_id': userId, + }); + } + + void leaveRoom({required String sessionId, String username = "anonymous"}) { + socket.emit('leave_room', { + 'session_id': sessionId, + 'username': username, + }); + } + + void sendMessage({ + required String sessionId, + required String message, + String username = "anonymous", + }) { + socket.emit('send_message', { + 'session_id': sessionId, + 'message': message, + 'username': username, + }); + } + + void dispose() { + socket.dispose(); + _roomMessageController.close(); + _chatMessageController.close(); + _errorController.close(); + } +} diff --git a/lib/feature/room_maker/binding/room_maker_binding.dart b/lib/feature/room_maker/binding/room_maker_binding.dart index 2e5157a..f2483c4 100644 --- a/lib/feature/room_maker/binding/room_maker_binding.dart +++ b/lib/feature/room_maker/binding/room_maker_binding.dart @@ -1,9 +1,12 @@ import 'package:get/get.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/services/session_service.dart'; import 'package:quiz_app/feature/room_maker/controller/room_maker_controller.dart'; class RoomMakerBinding extends Bindings { @override void dependencies() { - Get.lazyPut(() => RoomMakerController()); + Get.lazyPut(() => SessionService()); + Get.lazyPut(() => RoomMakerController(Get.find(), Get.find())); } } diff --git a/lib/feature/room_maker/controller/room_maker_controller.dart b/lib/feature/room_maker/controller/room_maker_controller.dart index 23d58ed..d77406e 100644 --- a/lib/feature/room_maker/controller/room_maker_controller.dart +++ b/lib/feature/room_maker/controller/room_maker_controller.dart @@ -1,9 +1,19 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/dto/waiting_room_dto.dart'; import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; +import 'package:quiz_app/data/models/session/session_request_model.dart'; +import 'package:quiz_app/data/services/session_service.dart'; class RoomMakerController extends GetxController { - final roomName = ''.obs; + final SessionService _sessionService; + final UserController _userController; + + RoomMakerController(this._sessionService, this._userController); + + // final roomName = ''.obs; final selectedQuiz = Rxn(); RxBool isOnwQuiz = true.obs; @@ -34,18 +44,24 @@ class RoomMakerController extends GetxController { ), ].obs; - void createRoom() { - if (roomName.value.trim().isEmpty || selectedQuiz.value == null) { + void onCreateRoom() async { + print("room ${nameTC.text} || ${selectedQuiz.value}"); + if (nameTC.text.trim().isEmpty || selectedQuiz.value == null) { Get.snackbar("Gagal", "Nama room dan kuis harus dipilih."); return; } final quiz = selectedQuiz.value!; - print("Membuat room:"); - print("- Nama: ${roomName.value}"); - print("- Quiz: ${quiz.title}"); - print("- Durasi: ${quiz.duration} detik"); - print("- Jumlah Soal: ${quiz.totalQuiz}"); + + final response = await _sessionService.createSession( + SessionRequestModel( + quizId: quiz.quizId, + hostId: _userController.userData!.id, + limitParticipan: int.parse(maxPlayerTC.text), + ), + ); + + if (response != null) Get.toNamed(AppRoutes.waitRoomPage, arguments: WaitingRoomDTO(true, response.data!)); } void onQuizSourceChange(bool base) { @@ -56,6 +72,4 @@ class RoomMakerController extends GetxController { final selected = availableQuizzes.firstWhere((e) => e.quizId == quizId); selectedQuiz.value = selected; } - - void onCreateRoom() {} } diff --git a/lib/feature/waiting_room/binding/waiting_room_binding.dart b/lib/feature/waiting_room/binding/waiting_room_binding.dart new file mode 100644 index 0000000..d26c263 --- /dev/null +++ b/lib/feature/waiting_room/binding/waiting_room_binding.dart @@ -0,0 +1,15 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/services/socket_service.dart'; +import 'package:quiz_app/feature/waiting_room/controller/waiting_room_controller.dart'; + +class WaitingRoomBinding extends Bindings { + @override + void dependencies() { + Get.put(SocketService()); + Get.lazyPut(() => WaitingRoomController( + Get.find(), + Get.find(), + )); + } +} diff --git a/lib/feature/waiting_room/controller/waiting_room_controller.dart b/lib/feature/waiting_room/controller/waiting_room_controller.dart new file mode 100644 index 0000000..bdb9698 --- /dev/null +++ b/lib/feature/waiting_room/controller/waiting_room_controller.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/dto/waiting_room_dto.dart'; +import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; +import 'package:quiz_app/data/models/session/session_response_model.dart'; +import 'package:quiz_app/data/models/user/user_model.dart'; +import 'package:quiz_app/data/services/socket_service.dart'; + +class WaitingRoomController extends GetxController { + final SocketService _socketService; + final UserController _userController; + WaitingRoomController(this._socketService, this._userController); + + final sessionCode = ''.obs; + final quizMeta = Rx(null); + final joinedUsers = [].obs; + + final isAdmin = true.obs; + + @override + void onInit() { + super.onInit(); + _loadDummyData(); + } + + void _loadDummyData() { + final data = Get.arguments as WaitingRoomDTO; + + SessionResponseModel? roomData = data.data; + isAdmin.value = data.isAdmin; + + _socketService.initSocketConnection(); + _socketService.joinRoom(sessionCode: roomData.sessionCode, userId: _userController.userData!.id); + + _socketService.roomMessages.listen((data) { + final user = data["data"]; + joinedUsers.assign(UserModel(id: user['user_id'], name: user['username'])); + }); + sessionCode.value = roomData.sessionCode; + + quizMeta.value = QuizListingModel( + quizId: "q123", + authorId: "a123", + authorName: "Admin", + title: "Uji Coba Kuis", + description: "Kuis untuk testing", + date: DateTime.now().toIso8601String(), + totalQuiz: 5, + duration: 900, + ); + } + + void copySessionCode(BuildContext context) { + Clipboard.setData(ClipboardData(text: sessionCode.value)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Session code disalin!')), + ); + } + + void addUser(UserModel user) { + joinedUsers.add(user); + } + + void startQuiz() { + print("Mulai kuis dengan session: ${sessionCode.value}"); + } +} diff --git a/lib/feature/waiting_room/view/waiting_room_view.dart b/lib/feature/waiting_room/view/waiting_room_view.dart new file mode 100644 index 0000000..e7f66e8 --- /dev/null +++ b/lib/feature/waiting_room/view/waiting_room_view.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/component/global_button.dart'; +import 'package:quiz_app/data/models/user/user_model.dart'; +import 'package:quiz_app/feature/waiting_room/controller/waiting_room_controller.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; + +class WaitingRoomView extends GetView { + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar(title: const Text("Waiting Room")), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Obx(() { + final session = controller.sessionCode.value; + final quiz = controller.quizMeta.value; + final users = controller.joinedUsers; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildQuizMeta(quiz), + const SizedBox(height: 20), + _buildSessionCode(context, session), + const SizedBox(height: 20), + const Text("Peserta yang Bergabung:", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 10), + Expanded(child: Obx(() => _buildUserList(users.toList()))), + const SizedBox(height: 16), + if (controller.isAdmin.value) + GlobalButton( + text: "Mulai Kuis", + onPressed: controller.startQuiz, + ), + ], + ); + }), + ), + ); + } + + Widget _buildSessionCode(BuildContext context, String code) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.primaryBlue.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.primaryBlue), + ), + child: Row( + children: [ + const Text("Session Code: ", style: TextStyle(fontWeight: FontWeight.bold)), + SelectableText(code, style: const TextStyle(fontSize: 16)), + const Spacer(), + IconButton( + icon: const Icon(Icons.copy), + tooltip: 'Salin Kode', + onPressed: () => controller.copySessionCode(context), + ), + ], + ), + ); + } + + Widget _buildQuizMeta(dynamic quiz) { + if (quiz == null) return const SizedBox.shrink(); + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.background, + border: Border.all(color: AppColors.borderLight), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text("Informasi Kuis:", style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Text("Judul: ${quiz.title}"), + Text("Deskripsi: ${quiz.description}"), + Text("Jumlah Soal: ${quiz.totalQuiz}"), + Text("Durasi: ${quiz.duration ~/ 60} menit"), + ], + ), + ); + } + + Widget _buildUserList(List users) { + return ListView.separated( + itemCount: users.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final user = users[index]; + return Container( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + decoration: BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.borderLight), + ), + child: Row( + children: [ + CircleAvatar(child: Text(user.name[0])), + const SizedBox(width: 12), + Text(user.name, style: const TextStyle(fontSize: 16)), + ], + ), + ); + }, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 942d1fc..f41ffd9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -248,6 +248,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.5.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" lucide_icons: dependency: "direct main" description: @@ -413,6 +421,22 @@ packages: description: flutter source: sdk version: "0.0.0" + socket_io_client: + dependency: "direct main" + description: + name: socket_io_client + sha256: c8471c2c6843cf308a5532ff653f2bcdb7fa9ae79d84d1179920578a06624f0d + url: "https://pub.dev" + source: hosted + version: "3.1.2" + socket_io_common: + dependency: transitive + description: + name: socket_io_common + sha256: "162fbaecbf4bf9a9372a62a341b3550b51dcef2f02f3e5830a297fd48203d45b" + url: "https://pub.dev" + source: hosted + version: "3.1.1" source_span: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index cad9a05..f304327 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,7 @@ dependencies: shared_preferences: ^2.5.3 lucide_icons: ^0.257.0 google_fonts: ^6.1.0 + socket_io_client: ^3.1.2 dev_dependencies: flutter_test: From 7e126e24a6b9fda7156dbe36be604cc320d263aa Mon Sep 17 00:00:00 2001 From: akhdanre Date: Wed, 7 May 2025 10:08:20 +0700 Subject: [PATCH 051/104] feat: done working on join room --- lib/app/routes/app_pages.dart | 7 ++ lib/app/routes/app_routes.dart | 1 + .../home/controller/home_controller.dart | 2 + lib/feature/home/view/home_page.dart | 2 +- .../join_room/binding/join_room_binding.dart | 13 ++++ .../controller/join_room_controller.dart | 39 ++++++++++ .../join_room/view/join_room_view.dart | 77 +++++++++++++++++++ .../binding/room_maker_binding.dart | 8 +- .../controller/room_maker_controller.dart | 16 +++- .../binding/waiting_room_binding.dart | 2 +- .../controller/waiting_room_controller.dart | 3 - 11 files changed, 161 insertions(+), 9 deletions(-) create mode 100644 lib/feature/join_room/binding/join_room_binding.dart create mode 100644 lib/feature/join_room/controller/join_room_controller.dart create mode 100644 lib/feature/join_room/view/join_room_view.dart diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index e815171..1537c4c 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -6,6 +6,8 @@ import 'package:quiz_app/feature/history/view/detail_history_view.dart'; import 'package:quiz_app/feature/home/binding/home_binding.dart'; import 'package:quiz_app/feature/home/view/home_page.dart'; import 'package:quiz_app/feature/detail_quiz/binding/detail_quiz_binding.dart'; +import 'package:quiz_app/feature/join_room/binding/join_room_binding.dart'; +import 'package:quiz_app/feature/join_room/view/join_room_view.dart'; import 'package:quiz_app/feature/library/binding/library_binding.dart'; import 'package:quiz_app/feature/detail_quiz/view/detail_quix_view.dart'; import 'package:quiz_app/feature/listing_quiz/binding/listing_quiz_binding.dart'; @@ -113,6 +115,11 @@ class AppPages { name: AppRoutes.waitRoomPage, page: () => WaitingRoomView(), binding: WaitingRoomBinding(), + ), + GetPage( + name: AppRoutes.joinRoomPage, + page: () => JoinRoomView(), + binding: JoinRoomBinding(), ) ]; } diff --git a/lib/app/routes/app_routes.dart b/lib/app/routes/app_routes.dart index bdb3b2b..14a39ee 100644 --- a/lib/app/routes/app_routes.dart +++ b/lib/app/routes/app_routes.dart @@ -19,5 +19,6 @@ abstract class AppRoutes { static const detailHistoryPage = "/history/detail"; static const roomPage = "/room/quiz"; + static const joinRoomPage = "/room/quiz/join"; static const waitRoomPage = "/room/quiz/waiting"; } diff --git a/lib/feature/home/controller/home_controller.dart b/lib/feature/home/controller/home_controller.dart index 4dff15d..d1e3bec 100644 --- a/lib/feature/home/controller/home_controller.dart +++ b/lib/feature/home/controller/home_controller.dart @@ -52,6 +52,8 @@ class HomeController extends GetxController { void goToRoomMaker() => Get.toNamed(AppRoutes.roomPage); + void goToJoinRoom() => Get.toNamed(AppRoutes.joinRoomPage); + void goToSearch() { final navController = Get.find(); navController.changePage(1); diff --git a/lib/feature/home/view/home_page.dart b/lib/feature/home/view/home_page.dart index 2cbddb6..caab0ec 100644 --- a/lib/feature/home/view/home_page.dart +++ b/lib/feature/home/view/home_page.dart @@ -36,7 +36,7 @@ class HomeView extends GetView { ButtonOption( onCreate: controller.goToQuizCreation, onCreateRoom: controller.goToRoomMaker, - onJoinRoom: () {}, + onJoinRoom: controller.goToJoinRoom, ), Padding( padding: const EdgeInsets.all(20), diff --git a/lib/feature/join_room/binding/join_room_binding.dart b/lib/feature/join_room/binding/join_room_binding.dart new file mode 100644 index 0000000..cde17c8 --- /dev/null +++ b/lib/feature/join_room/binding/join_room_binding.dart @@ -0,0 +1,13 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/services/socket_service.dart'; +import 'package:quiz_app/feature/join_room/controller/join_room_controller.dart'; + +class JoinRoomBinding extends Bindings { + @override + void dependencies() { + Get.put(SocketService()); + + Get.lazyPut(() => JoinRoomController(Get.find(), Get.find())); + } +} diff --git a/lib/feature/join_room/controller/join_room_controller.dart b/lib/feature/join_room/controller/join_room_controller.dart new file mode 100644 index 0000000..a92ecf4 --- /dev/null +++ b/lib/feature/join_room/controller/join_room_controller.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/dto/waiting_room_dto.dart'; +import 'package:quiz_app/data/models/session/session_response_model.dart'; +import 'package:quiz_app/data/services/socket_service.dart'; + +class JoinRoomController extends GetxController { + final SocketService _socketService; + final UserController _userController; + + JoinRoomController(this._socketService, this._userController); + + final TextEditingController codeController = TextEditingController(); + + void joinRoom() { + final code = codeController.text.trim(); + + if (code.isEmpty) { + Get.snackbar( + "Error", + "Kode room dan nama harus diisi", + backgroundColor: Get.theme.colorScheme.error.withOpacity(0.9), + colorText: Colors.white, + ); + return; + } + _socketService.initSocketConnection(); + _socketService.joinRoom(sessionCode: code, userId: _userController.userData!.id); + Get.toNamed(AppRoutes.waitRoomPage, arguments: WaitingRoomDTO(false, SessionResponseModel(sessionId: "", sessionCode: code))); + } + + @override + void onClose() { + codeController.dispose(); + super.onClose(); + } +} diff --git a/lib/feature/join_room/view/join_room_view.dart b/lib/feature/join_room/view/join_room_view.dart new file mode 100644 index 0000000..8e8c860 --- /dev/null +++ b/lib/feature/join_room/view/join_room_view.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/component/global_button.dart'; +import 'package:quiz_app/component/global_text_field.dart'; +import 'package:quiz_app/feature/join_room/controller/join_room_controller.dart'; + +class JoinRoomView extends GetView { + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.background, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black87), + onPressed: () => Get.back(), + ), + ), + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.06), + blurRadius: 14, + offset: const Offset(0, 6), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Masukkan Kode Room", + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: Colors.black87, + ), + ), + const SizedBox(height: 16), + GlobalTextField( + controller: controller.codeController, + hintText: "AB123C", + textInputType: TextInputType.text, + // Uncomment if needed: + // maxLength: 6, + // inputFormatters: [ + // FilteringTextInputFormatter.allow(RegExp(r'[A-Z0-9]')), + // UpperCaseTextFormatter(), + // ], + ), + const SizedBox(height: 30), + GlobalButton( + text: "Gabung Sekarang", + onPressed: controller.joinRoom, + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/feature/room_maker/binding/room_maker_binding.dart b/lib/feature/room_maker/binding/room_maker_binding.dart index f2483c4..65463ed 100644 --- a/lib/feature/room_maker/binding/room_maker_binding.dart +++ b/lib/feature/room_maker/binding/room_maker_binding.dart @@ -1,12 +1,18 @@ import 'package:get/get.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/services/session_service.dart'; +import 'package:quiz_app/data/services/socket_service.dart'; import 'package:quiz_app/feature/room_maker/controller/room_maker_controller.dart'; class RoomMakerBinding extends Bindings { @override void dependencies() { Get.lazyPut(() => SessionService()); - Get.lazyPut(() => RoomMakerController(Get.find(), Get.find())); + Get.put(SocketService()); + Get.lazyPut(() => RoomMakerController( + Get.find(), + Get.find(), + Get.find(), + )); } } diff --git a/lib/feature/room_maker/controller/room_maker_controller.dart b/lib/feature/room_maker/controller/room_maker_controller.dart index d77406e..f301fb3 100644 --- a/lib/feature/room_maker/controller/room_maker_controller.dart +++ b/lib/feature/room_maker/controller/room_maker_controller.dart @@ -6,12 +6,18 @@ import 'package:quiz_app/data/dto/waiting_room_dto.dart'; import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; import 'package:quiz_app/data/models/session/session_request_model.dart'; import 'package:quiz_app/data/services/session_service.dart'; +import 'package:quiz_app/data/services/socket_service.dart'; class RoomMakerController extends GetxController { final SessionService _sessionService; final UserController _userController; + final SocketService _socketService; - RoomMakerController(this._sessionService, this._userController); + RoomMakerController( + this._sessionService, + this._userController, + this._socketService, + ); // final roomName = ''.obs; final selectedQuiz = Rxn(); @@ -45,7 +51,6 @@ class RoomMakerController extends GetxController { ].obs; void onCreateRoom() async { - print("room ${nameTC.text} || ${selectedQuiz.value}"); if (nameTC.text.trim().isEmpty || selectedQuiz.value == null) { Get.snackbar("Gagal", "Nama room dan kuis harus dipilih."); return; @@ -61,7 +66,12 @@ class RoomMakerController extends GetxController { ), ); - if (response != null) Get.toNamed(AppRoutes.waitRoomPage, arguments: WaitingRoomDTO(true, response.data!)); + if (response != null) { + _socketService.initSocketConnection(); + _socketService.joinRoom(sessionCode: response.data!.sessionCode, userId: _userController.userData!.id); + + Get.toNamed(AppRoutes.waitRoomPage, arguments: WaitingRoomDTO(true, response.data!)); + } } void onQuizSourceChange(bool base) { diff --git a/lib/feature/waiting_room/binding/waiting_room_binding.dart b/lib/feature/waiting_room/binding/waiting_room_binding.dart index d26c263..1fb5fbc 100644 --- a/lib/feature/waiting_room/binding/waiting_room_binding.dart +++ b/lib/feature/waiting_room/binding/waiting_room_binding.dart @@ -6,7 +6,7 @@ import 'package:quiz_app/feature/waiting_room/controller/waiting_room_controller class WaitingRoomBinding extends Bindings { @override void dependencies() { - Get.put(SocketService()); + if (!Get.isRegistered()) Get.put(SocketService()); Get.lazyPut(() => WaitingRoomController( Get.find(), Get.find(), diff --git a/lib/feature/waiting_room/controller/waiting_room_controller.dart b/lib/feature/waiting_room/controller/waiting_room_controller.dart index bdb9698..d023666 100644 --- a/lib/feature/waiting_room/controller/waiting_room_controller.dart +++ b/lib/feature/waiting_room/controller/waiting_room_controller.dart @@ -31,9 +31,6 @@ class WaitingRoomController extends GetxController { SessionResponseModel? roomData = data.data; isAdmin.value = data.isAdmin; - _socketService.initSocketConnection(); - _socketService.joinRoom(sessionCode: roomData.sessionCode, userId: _userController.userData!.id); - _socketService.roomMessages.listen((data) { final user = data["data"]; joinedUsers.assign(UserModel(id: user['user_id'], name: user['username'])); From edb7ab0fdfe54128f964274e3d332dab7b0f7b08 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Wed, 7 May 2025 12:32:33 +0700 Subject: [PATCH 052/104] feat: play quiz multiplayer done --- lib/app/routes/app_pages.dart | 15 ++- lib/app/routes/app_routes.dart | 3 + lib/data/services/socket_service.dart | 59 ++++++++++-- .../monitor_quiz/view/monitor_quiz_view.dart | 10 ++ .../play_quiz_multiplayer_binding.dart | 11 +++ .../controller/play_quiz_controller.dart | 91 +++++++++++++++++++ .../view/play_quiz_multiplayer.dart | 79 ++++++++++++++++ .../controller/waiting_room_controller.dart | 41 +++++++-- 8 files changed, 290 insertions(+), 19 deletions(-) create mode 100644 lib/feature/monitor_quiz/view/monitor_quiz_view.dart create mode 100644 lib/feature/play_quiz_multiplayer/binding/play_quiz_multiplayer_binding.dart create mode 100644 lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart create mode 100644 lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index 1537c4c..8344c77 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -14,8 +14,11 @@ import 'package:quiz_app/feature/listing_quiz/binding/listing_quiz_binding.dart' import 'package:quiz_app/feature/listing_quiz/view/listing_quiz_view.dart'; import 'package:quiz_app/feature/login/bindings/login_binding.dart'; import 'package:quiz_app/feature/login/view/login_page.dart'; +import 'package:quiz_app/feature/monitor_quiz/view/monitor_quiz_view.dart'; import 'package:quiz_app/feature/navigation/bindings/navigation_binding.dart'; import 'package:quiz_app/feature/navigation/views/navbar_view.dart'; +import 'package:quiz_app/feature/play_quiz_multiplayer/binding/play_quiz_multiplayer_binding.dart'; +import 'package:quiz_app/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart'; import 'package:quiz_app/feature/profile/binding/profile_binding.dart'; import 'package:quiz_app/feature/quiz_creation/binding/quiz_creation_binding.dart'; import 'package:quiz_app/feature/quiz_creation/view/quiz_creation_view.dart'; @@ -120,6 +123,16 @@ class AppPages { name: AppRoutes.joinRoomPage, page: () => JoinRoomView(), binding: JoinRoomBinding(), - ) + ), + GetPage( + name: AppRoutes.monitorQuizMPLPage, + page: () => MonitorQuizView(), + // binding: JoinRoomBinding(), + ), + GetPage( + name: AppRoutes.playQuizMPLPage, + page: () => PlayQuizMultiplayerView(), + binding: PlayQuizMultiplayerBinding(), + ), ]; } diff --git a/lib/app/routes/app_routes.dart b/lib/app/routes/app_routes.dart index 14a39ee..3274125 100644 --- a/lib/app/routes/app_routes.dart +++ b/lib/app/routes/app_routes.dart @@ -21,4 +21,7 @@ abstract class AppRoutes { static const roomPage = "/room/quiz"; static const joinRoomPage = "/room/quiz/join"; static const waitRoomPage = "/room/quiz/waiting"; + + static const playQuizMPLPage = "/room/quiz/play"; + static const monitorQuizMPLPage = "/room/quiz/monitor"; } diff --git a/lib/data/services/socket_service.dart b/lib/data/services/socket_service.dart index 4edd78c..b2b8d27 100644 --- a/lib/data/services/socket_service.dart +++ b/lib/data/services/socket_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:quiz_app/core/endpoint/api_endpoint.dart'; +import 'package:quiz_app/core/utils/logger.dart'; import 'package:socket_io_client/socket_io_client.dart' as io; class SocketService { @@ -7,47 +8,51 @@ class SocketService { final _roomMessageController = StreamController>.broadcast(); final _chatMessageController = StreamController>.broadcast(); + final _quizStartedController = StreamController.broadcast(); final _errorController = StreamController.broadcast(); Stream> get roomMessages => _roomMessageController.stream; Stream> get chatMessages => _chatMessageController.stream; + Stream get quizStarted => _quizStartedController.stream; Stream get errors => _errorController.stream; void initSocketConnection() { socket = io.io( APIEndpoint.baseUrl, - io.OptionBuilder() - .setTransports(['websocket']) // WebSocket mode - .disableAutoConnect() - .build(), + io.OptionBuilder().setTransports(['websocket']).disableAutoConnect().build(), ); socket.connect(); socket.onConnect((_) { - print('Connected: ${socket.id}'); + logC.i('Connected: ${socket.id}'); }); socket.onDisconnect((_) { - print('Disconnected'); + logC.i('Disconnected'); }); socket.on('connection_response', (data) { - print('Connection response: $data'); + logC.i('Connection response: $data'); }); socket.on('room_message', (data) { - print('Room Message: $data'); + logC.i('Room Message: $data'); _roomMessageController.add(Map.from(data)); }); socket.on('receive_message', (data) { - print('Message from ${data['from']}: ${data['message']}'); + logC.i('Message from ${data['from']}: ${data['message']}'); _chatMessageController.add(Map.from(data)); }); + socket.on('quiz_started', (_) { + logC.i('Quiz has started!'); + _quizStartedController.add(null); + }); + socket.on('error', (data) { - print('Socket error: $data'); + logC.i('Socket error: $data'); _errorController.add(data.toString()); }); } @@ -78,10 +83,44 @@ class SocketService { }); } + /// Emit when admin starts the quiz + void startQuiz({required String sessionCode}) { + socket.emit('start_quiz', { + 'session_code': sessionCode, + }); + } + + /// Emit user's answer during quiz + void sendAnswer({ + required String sessionId, + required String userId, + required int questionIndex, + required dynamic answer, + }) { + socket.emit('submit_answer', { + 'session_id': sessionId, + 'user_id': userId, + 'question_index': questionIndex, + 'answer': answer, + }); + } + + /// Emit when user finishes the quiz + void doneQuiz({ + required String sessionId, + required String userId, + }) { + socket.emit('quiz_done', { + 'session_id': sessionId, + 'user_id': userId, + }); + } + void dispose() { socket.dispose(); _roomMessageController.close(); _chatMessageController.close(); + _quizStartedController.close(); _errorController.close(); } } diff --git a/lib/feature/monitor_quiz/view/monitor_quiz_view.dart b/lib/feature/monitor_quiz/view/monitor_quiz_view.dart new file mode 100644 index 0000000..9409ae9 --- /dev/null +++ b/lib/feature/monitor_quiz/view/monitor_quiz_view.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class MonitorQuizView extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + body: Text("monitor quiz admin"), + ); + } +} diff --git a/lib/feature/play_quiz_multiplayer/binding/play_quiz_multiplayer_binding.dart b/lib/feature/play_quiz_multiplayer/binding/play_quiz_multiplayer_binding.dart new file mode 100644 index 0000000..5c92b1f --- /dev/null +++ b/lib/feature/play_quiz_multiplayer/binding/play_quiz_multiplayer_binding.dart @@ -0,0 +1,11 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/services/socket_service.dart'; +import 'package:quiz_app/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart'; + +class PlayQuizMultiplayerBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => PlayQuizMultiplayerController(Get.find(), Get.find())); + } +} diff --git a/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart b/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart new file mode 100644 index 0000000..a8356d9 --- /dev/null +++ b/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart @@ -0,0 +1,91 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/services/socket_service.dart'; + +class PlayQuizMultiplayerController extends GetxController { + final SocketService _socketService; + final UserController _userController; + + PlayQuizMultiplayerController(this._socketService, this._userController); + + final questions = [].obs; + final Rxn currentQuestion = Rxn(); + final currentQuestionIndex = 0.obs; + final selectedAnswer = Rxn(); + final isDone = false.obs; + + late final String sessionCode; + late final bool isAdmin; + + @override + void onInit() { + super.onInit(); + + final args = Get.arguments as Map; + sessionCode = args["session_code"]; + isAdmin = args["is_admin"]; + + _socketService.socket.on("quiz_question", (data) { + final model = MultiplayerQuestionModel.fromJson(Map.from(data)); + currentQuestion.value = model; + questions.add(model); + }); + + _socketService.socket.on("quiz_done", (_) { + isDone.value = true; + }); + } + + void submitAnswer() { + final question = questions[currentQuestionIndex.value]; + final answer = selectedAnswer.value; + + if (answer != null) { + _socketService.sendAnswer( + sessionId: sessionCode, + userId: _userController.userData!.id, + questionIndex: question.questionIndex, + answer: answer, + ); + } + + if (currentQuestionIndex.value < questions.length - 1) { + currentQuestionIndex.value++; + selectedAnswer.value = null; + } else { + isDone.value = true; + _socketService.doneQuiz( + sessionId: sessionCode, + userId: _userController.userData!.id, + ); + } + } +} + +class MultiplayerQuestionModel { + final int questionIndex; + final String question; + final List options; + + MultiplayerQuestionModel({ + required this.questionIndex, + required this.question, + required this.options, + }); + + factory MultiplayerQuestionModel.fromJson(Map json) { + return MultiplayerQuestionModel( + questionIndex: json['question_index'], + question: json['question'], + options: List.from(json['options']), + ); + } + + Map toJson() { + return { + 'question_index': questionIndex, + 'question': question, + 'options': options, + }; + } +} diff --git a/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart b/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart new file mode 100644 index 0000000..8ee2ad7 --- /dev/null +++ b/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart'; + +class PlayQuizMultiplayerView extends GetView { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Obx(() { + if (controller.questions.isEmpty) { + return const Text("Loading..."); + } + return Text("Soal ${controller.currentQuestionIndex.value + 1}/${controller.questions.length}"); + }), + ), + body: Obx(() { + if (controller.isDone.value) { + return _buildDoneView(); + } + + if (controller.questions.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + return _buildQuestionView(); + }), + ); + } + + Widget _buildQuestionView() { + return Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + controller.currentQuestion.value!.question, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 20), + ...controller.currentQuestion.value!.options.map((option) => RadioListTile( + title: Text(option), + value: option, + groupValue: controller.selectedAnswer.value, + onChanged: (value) => controller.selectedAnswer.value = value, + )), + const Spacer(), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: controller.selectedAnswer.value == null ? null : controller.submitAnswer, + child: const Text("Kirim Jawaban"), + ), + ), + ], + ), + ); + } + + Widget _buildDoneView() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + "Kuis telah selesai!", + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () {}, + child: const Text("Lihat Hasil"), + ), + ], + ), + ); + } +} diff --git a/lib/feature/waiting_room/controller/waiting_room_controller.dart b/lib/feature/waiting_room/controller/waiting_room_controller.dart index d023666..e177e4f 100644 --- a/lib/feature/waiting_room/controller/waiting_room_controller.dart +++ b/lib/feature/waiting_room/controller/waiting_room_controller.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/dto/waiting_room_dto.dart'; import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; @@ -16,25 +17,24 @@ class WaitingRoomController extends GetxController { final sessionCode = ''.obs; final quizMeta = Rx(null); final joinedUsers = [].obs; - final isAdmin = true.obs; + final quizQuestions = >[].obs; + final isQuizStarted = false.obs; + @override void onInit() { super.onInit(); - _loadDummyData(); + _loadInitialData(); + _registerSocketListeners(); } - void _loadDummyData() { + void _loadInitialData() { final data = Get.arguments as WaitingRoomDTO; SessionResponseModel? roomData = data.data; isAdmin.value = data.isAdmin; - _socketService.roomMessages.listen((data) { - final user = data["data"]; - joinedUsers.assign(UserModel(id: user['user_id'], name: user['username'])); - }); sessionCode.value = roomData.sessionCode; quizMeta.value = QuizListingModel( @@ -49,6 +49,31 @@ class WaitingRoomController extends GetxController { ); } + void _registerSocketListeners() { + _socketService.roomMessages.listen((data) { + final user = data["data"]; + if (user != null) { + joinedUsers.assign(UserModel(id: user['user_id'], name: user['username'])); + } + }); + + _socketService.quizStarted.listen((_) { + isQuizStarted.value = true; + Get.snackbar("Info", "Kuis telah dimulai"); + if (isAdmin.value) { + Get.offAllNamed(AppRoutes.monitorQuizMPLPage, arguments: { + "session_code": sessionCode.value, + "is_admin": isAdmin.value, + }); + } else { + Get.offAllNamed(AppRoutes.playQuizMPLPage, arguments: { + "session_code": sessionCode.value, + "is_admin": isAdmin.value, + }); + } + }); + } + void copySessionCode(BuildContext context) { Clipboard.setData(ClipboardData(text: sessionCode.value)); ScaffoldMessenger.of(context).showSnackBar( @@ -61,6 +86,6 @@ class WaitingRoomController extends GetxController { } void startQuiz() { - print("Mulai kuis dengan session: ${sessionCode.value}"); + _socketService.startQuiz(sessionCode: sessionCode.value); } } From dda268d2d518d1abd36a8e8007622f4e4583978d Mon Sep 17 00:00:00 2001 From: akhdanre Date: Wed, 7 May 2025 18:07:24 +0700 Subject: [PATCH 053/104] feat: preparaiion on socket connection --- lib/core/endpoint/api_endpoint.dart | 3 +- .../controller/play_quiz_controller.dart | 42 +++++- .../view/play_quiz_multiplayer.dart | 123 +++++++++++++++--- .../binding/waiting_room_binding.dart | 6 +- .../controller/waiting_room_controller.dart | 4 +- 5 files changed, 152 insertions(+), 26 deletions(-) diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart index 4611a3d..d518cfb 100644 --- a/lib/core/endpoint/api_endpoint.dart +++ b/lib/core/endpoint/api_endpoint.dart @@ -1,5 +1,6 @@ class APIEndpoint { - static const String baseUrl = "http://192.168.1.9:5000"; + // static const String baseUrl = "http://192.168.1.9:5000"; + static const String baseUrl = "http://172.16.106.133:5000"; static const String api = "$baseUrl/api"; static const String login = "/login"; diff --git a/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart b/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart index a8356d9..204ab4b 100644 --- a/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart +++ b/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/services/socket_service.dart'; @@ -14,6 +15,8 @@ class PlayQuizMultiplayerController extends GetxController { final selectedAnswer = Rxn(); final isDone = false.obs; + final fillInAnswerController = TextEditingController(); + late final String sessionCode; late final bool isAdmin; @@ -29,6 +32,7 @@ class PlayQuizMultiplayerController extends GetxController { final model = MultiplayerQuestionModel.fromJson(Map.from(data)); currentQuestion.value = model; questions.add(model); + fillInAnswerController.clear(); // reset tiap soal baru }); _socketService.socket.on("quiz_done", (_) { @@ -36,11 +40,34 @@ class PlayQuizMultiplayerController extends GetxController { }); } + bool isAnswerSelected() { + final type = currentQuestion.value?.type; + if (type == 'fill_in_the_blank') { + return fillInAnswerController.text.trim().isNotEmpty; + } + return selectedAnswer.value != null; + } + + void selectOptionAnswer(String option) { + selectedAnswer.value = option; + } + + void selectTrueFalseAnswer(bool value) { + selectedAnswer.value = value.toString(); + } + void submitAnswer() { final question = questions[currentQuestionIndex.value]; - final answer = selectedAnswer.value; + final type = question.type; - if (answer != null) { + String? answer; + if (type == 'fill_in_the_blank') { + answer = fillInAnswerController.text.trim(); + } else { + answer = selectedAnswer.value; + } + + if (answer != null && answer.isNotEmpty) { _socketService.sendAnswer( sessionId: sessionCode, userId: _userController.userData!.id, @@ -52,6 +79,7 @@ class PlayQuizMultiplayerController extends GetxController { if (currentQuestionIndex.value < questions.length - 1) { currentQuestionIndex.value++; selectedAnswer.value = null; + fillInAnswerController.clear(); } else { isDone.value = true; _socketService.doneQuiz( @@ -60,16 +88,24 @@ class PlayQuizMultiplayerController extends GetxController { ); } } + + @override + void onClose() { + fillInAnswerController.dispose(); + super.onClose(); + } } class MultiplayerQuestionModel { final int questionIndex; final String question; + final String type; // 'option', 'true_false', 'fill_in_the_blank' final List options; MultiplayerQuestionModel({ required this.questionIndex, required this.question, + required this.type, required this.options, }); @@ -77,6 +113,7 @@ class MultiplayerQuestionModel { return MultiplayerQuestionModel( questionIndex: json['question_index'], question: json['question'], + type: json['type'], options: List.from(json['options']), ); } @@ -85,6 +122,7 @@ class MultiplayerQuestionModel { return { 'question_index': questionIndex, 'question': question, + 'type': type, 'options': options, }; } diff --git a/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart b/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart index 8ee2ad7..9055503 100644 --- a/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart +++ b/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart @@ -1,17 +1,28 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/component/global_text_field.dart'; import 'package:quiz_app/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart'; class PlayQuizMultiplayerView extends GetView { @override Widget build(BuildContext context) { return Scaffold( + backgroundColor: const Color(0xFFF9FAFB), appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + centerTitle: true, title: Obx(() { if (controller.questions.isEmpty) { - return const Text("Loading..."); + return const Text( + "Loading...", + style: TextStyle(color: Colors.black, fontWeight: FontWeight.bold), + ); } - return Text("Soal ${controller.currentQuestionIndex.value + 1}/${controller.questions.length}"); + return Text( + "Soal ${controller.currentQuestionIndex.value + 1}/${controller.questions.length}", + style: const TextStyle(color: Colors.black, fontWeight: FontWeight.bold), + ); }), ), body: Obx(() { @@ -29,28 +40,36 @@ class PlayQuizMultiplayerView extends GetView { } Widget _buildQuestionView() { + final question = controller.currentQuestion.value!; return Padding( padding: const EdgeInsets.all(20.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - controller.currentQuestion.value!.question, - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + question.question, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black), ), const SizedBox(height: 20), - ...controller.currentQuestion.value!.options.map((option) => RadioListTile( - title: Text(option), - value: option, - groupValue: controller.selectedAnswer.value, - onChanged: (value) => controller.selectedAnswer.value = value, - )), + if (question.type == 'option') _buildOptionQuestion(), + if (question.type == 'fill_in_the_blank') _buildFillInBlankQuestion(), + if (question.type == 'true_false') _buildTrueFalseQuestion(), + const Spacer(), SizedBox( width: double.infinity, child: ElevatedButton( - onPressed: controller.selectedAnswer.value == null ? null : controller.submitAnswer, - child: const Text("Kirim Jawaban"), + onPressed: controller.isAnswerSelected() ? controller.submitAnswer : null, + style: ElevatedButton.styleFrom( + backgroundColor: controller.isAnswerSelected() ? const Color(0xFF2563EB) : Colors.grey, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: const Text( + "Kirim Jawaban", + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), ), ), ], @@ -58,8 +77,74 @@ class PlayQuizMultiplayerView extends GetView { ); } + Widget _buildOptionQuestion() { + final options = controller.currentQuestion.value!.options; + return Column( + children: List.generate(options.length, (index) { + final option = options[index]; + final isSelected = controller.selectedAnswer.value == option; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: isSelected ? const Color(0xFF2563EB) : Colors.white, + foregroundColor: isSelected ? Colors.white : Colors.black, + side: const BorderSide(color: Colors.grey), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + onPressed: () => controller.selectOptionAnswer(option), + child: Text(option), + ), + ); + }), + ); + } + + Widget _buildFillInBlankQuestion() { + return Column( + children: [ + GlobalTextField(controller: controller.fillInAnswerController), + const SizedBox(height: 20), + ], + ); + } + + Widget _buildTrueFalseQuestion() { + return Column( + children: [ + _buildTrueFalseButton('Ya', true), + _buildTrueFalseButton('Tidak', false), + ], + ); + } + + Widget _buildTrueFalseButton(String label, bool value) { + final isSelected = controller.selectedAnswer.value == value.toString(); + + return Container( + margin: const EdgeInsets.only(bottom: 12), + width: double.infinity, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: isSelected ? (value ? Colors.green : Colors.red) : Colors.white, + foregroundColor: isSelected ? Colors.white : Colors.black, + side: const BorderSide(color: Colors.grey), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + onPressed: () => controller.selectTrueFalseAnswer(value), + icon: Icon(value ? Icons.check_circle_outline : Icons.cancel_outlined), + label: Text(label), + ), + ); + } + Widget _buildDoneView() { - return Center( + return Padding( + padding: const EdgeInsets.all(20), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -69,8 +154,16 @@ class PlayQuizMultiplayerView extends GetView { ), const SizedBox(height: 16), ElevatedButton( - onPressed: () {}, - child: const Text("Lihat Hasil"), + onPressed: () { + // Arahkan ke halaman hasil atau leaderboard + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF2563EB), + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: const Text("Lihat Hasil", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), ), ], ), diff --git a/lib/feature/waiting_room/binding/waiting_room_binding.dart b/lib/feature/waiting_room/binding/waiting_room_binding.dart index 1fb5fbc..ccd1b1c 100644 --- a/lib/feature/waiting_room/binding/waiting_room_binding.dart +++ b/lib/feature/waiting_room/binding/waiting_room_binding.dart @@ -1,5 +1,4 @@ import 'package:get/get.dart'; -import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/services/socket_service.dart'; import 'package:quiz_app/feature/waiting_room/controller/waiting_room_controller.dart'; @@ -7,9 +6,6 @@ class WaitingRoomBinding extends Bindings { @override void dependencies() { if (!Get.isRegistered()) Get.put(SocketService()); - Get.lazyPut(() => WaitingRoomController( - Get.find(), - Get.find(), - )); + Get.lazyPut(() => WaitingRoomController(Get.find())); } } diff --git a/lib/feature/waiting_room/controller/waiting_room_controller.dart b/lib/feature/waiting_room/controller/waiting_room_controller.dart index e177e4f..856231c 100644 --- a/lib/feature/waiting_room/controller/waiting_room_controller.dart +++ b/lib/feature/waiting_room/controller/waiting_room_controller.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; -import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/dto/waiting_room_dto.dart'; import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; import 'package:quiz_app/data/models/session/session_response_model.dart'; @@ -11,8 +10,7 @@ import 'package:quiz_app/data/services/socket_service.dart'; class WaitingRoomController extends GetxController { final SocketService _socketService; - final UserController _userController; - WaitingRoomController(this._socketService, this._userController); + WaitingRoomController(this._socketService); final sessionCode = ''.obs; final quizMeta = Rx(null); From b575f75f6dcd545e98ecff2602c4e605d532b68b Mon Sep 17 00:00:00 2001 From: akhdanre Date: Wed, 7 May 2025 18:26:12 +0700 Subject: [PATCH 054/104] feat: adding limit in the request with loading not dissmisable --- lib/core/utils/custom_floating_loading.dart | 23 +++++++++ .../controller/quiz_preview_controller.dart | 49 ++++++++++++------- 2 files changed, 53 insertions(+), 19 deletions(-) create mode 100644 lib/core/utils/custom_floating_loading.dart diff --git a/lib/core/utils/custom_floating_loading.dart b/lib/core/utils/custom_floating_loading.dart new file mode 100644 index 0000000..2ac487d --- /dev/null +++ b/lib/core/utils/custom_floating_loading.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +class CustomFloatingLoading { + static void showLoadingDialog(BuildContext context) { + showDialog( + context: context, + barrierDismissible: false, + barrierColor: Colors.black.withValues(alpha: 0.3), + builder: (BuildContext context) { + return PopScope( + canPop: false, + child: const Center( + child: CircularProgressIndicator(), + ), + ); + }, + ); + } + + static void hideLoadingDialog(BuildContext context) { + Navigator.of(context).pop(); + } +} diff --git a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart index c555aef..e9dad1b 100644 --- a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart +++ b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/const/enums/question_type.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/core/utils/custom_floating_loading.dart'; import 'package:quiz_app/core/utils/custom_notification.dart'; import 'package:quiz_app/core/utils/logger.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; @@ -29,6 +30,8 @@ class QuizPreviewController extends GetxController { RxBool isPublic = false.obs; + RxBool isLoading = false.obs; + late final List data; RxList subjects = [].obs; @@ -62,26 +65,31 @@ class QuizPreviewController extends GetxController { } Future onSaveQuiz() async { - final title = titleController.text.trim(); - final description = descriptionController.text.trim(); - - if (title.isEmpty || description.isEmpty) { - CustomNotification.error( - title: 'Error', - message: 'Judul dan deskripsi tidak boleh kosong!', - ); - return; - } - - if (data.length < 10) { - CustomNotification.error( - title: 'Error', - message: 'Jumlah soal harus 10 atau lebih', - ); - return; - } - try { + if (isLoading.value) return; + + final title = titleController.text.trim(); + final description = descriptionController.text.trim(); + + if (title.isEmpty || description.isEmpty) { + CustomNotification.error( + title: 'Error', + message: 'Judul dan deskripsi tidak boleh kosong!', + ); + return; + } + + if (data.length < 10) { + CustomNotification.error( + title: 'Error', + message: 'Jumlah soal harus 10 atau lebih', + ); + return; + } + + isLoading.value = true; + CustomFloatingLoading.showLoadingDialog(Get.context!); + final now = DateTime.now(); final String formattedDate = "${now.day.toString().padLeft(2, '0')}-${now.month.toString().padLeft(2, '0')}-${now.year}"; @@ -108,6 +116,9 @@ class QuizPreviewController extends GetxController { } } catch (e) { logC.e(e); + } finally { + isLoading.value = false; + CustomFloatingLoading.hideLoadingDialog(Get.context!); } } From 7dc09941620f7c3dbb2aca948af774f173418842 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Wed, 7 May 2025 21:45:36 +0700 Subject: [PATCH 055/104] feat: adding localization --- assets/translations/en-US.json | 83 +++++++++++++++++++ assets/translations/id-ID.json | 83 +++++++++++++++++++ assets/translations/ms-MY.json | 83 +++++++++++++++++++ lib/app/app.dart | 4 + .../widget/quiz_item_wa_component.dart | 7 +- .../widget/recomendation_component.dart | 7 +- lib/core/endpoint/api_endpoint.dart | 4 +- .../detail_quiz/view/detail_quix_view.dart | 10 ++- .../history/view/detail_history_view.dart | 15 ++-- lib/feature/history/view/history_view.dart | 11 +-- .../home/view/component/button_option.dart | 15 ++-- .../home/view/component/search_component.dart | 19 +++-- .../home/view/component/user_gretings.dart | 5 +- lib/feature/home/view/home_page.dart | 3 +- .../join_room/view/join_room_view.dart | 18 ++-- lib/feature/library/view/library_view.dart | 30 ++++--- .../listing_quiz/view/listing_quiz_view.dart | 8 +- lib/feature/login/view/login_page.dart | 23 ++--- lib/feature/navigation/views/navbar_view.dart | 25 +++--- .../view/play_quiz_multiplayer.dart | 1 - lib/feature/profile/view/profile_view.dart | 28 +++++-- .../view/quiz_creation_view.dart | 41 ++++----- .../quiz_play/view/quiz_play_view.dart | 46 +++++----- .../quiz_preview/view/quiz_preview.dart | 33 ++++---- lib/feature/register/view/register_page.dart | 41 +++++---- lib/feature/search/view/search_view.dart | 5 +- lib/main.dart | 17 +++- pubspec.lock | 37 +++++++++ pubspec.yaml | 2 + 29 files changed, 530 insertions(+), 174 deletions(-) create mode 100644 assets/translations/en-US.json create mode 100644 assets/translations/id-ID.json create mode 100644 assets/translations/ms-MY.json diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json new file mode 100644 index 0000000..6e6f10c --- /dev/null +++ b/assets/translations/en-US.json @@ -0,0 +1,83 @@ +{ + "greeting_time": "Good Afternoon", + "greeting_user": "Hello {user}", + "create_room": "Create Room", + "join_room": "Join Room", + "create_quiz": "Create Quiz", + "ready_new_challenge": "Ready for a new challenge?", + "search_or_select_category": "Search or select by category", + "search_for_quizzes": "Search for quizzes...", + "quiz_recommendation": "Recommended Quiz", + "log_in": "Log In", + "sign_in": "Sign In", + "email": "Email", + "enter_your_email": "Enter Your Email", + "password": "Password", + "enter_your_password": "Enter Your Password", + "or": "OR", + "register_title": "Register", + "full_name": "Full Name", + "birth_date": "Birth Date", + "phone_optional": "Phone Number (Optional)", + "verify_password": "Verify Password", + "register_button": "Register", + "nav_home": "Home", + "nav_search": "Search", + "nav_library": "Library", + "nav_history": "History", + "nav_profile": "Profile", + "quiz_popular": "Popular Quiz", + "see_all": "See All", + + "library_title": "Quiz Library", + "library_description": "A collection of quiz questions created for study.", + "no_quiz_available": "No quizzes available yet.", + "quiz_count_label": "Quizzes", + "quiz_count_named": "{total} Quizzes", + + "history_title": "Quiz History", + "history_subtitle": "Review the quizzes you've taken", + "no_history": "You don't have any quiz history yet", + "score_label": "Score: {correct}/{total}", + "duration_minutes": "{minute} minutes", + + "edit_profile": "Edit Profile", + "logout": "Logout", + "total_quiz": "Total Quiz", + "avg_score": "Average Score", + + "history_detail_title": "Quiz Detail", + "correct_answer": "Correct", + "score": "Score", + "time_taken": "Time", + "duration_seconds": "{second}s", + + "your_answer": "Your answer: {answer}", + "question_type_option": "Multiple Choice", + "question_type_fill": "Fill in the Blank", + "question_type_true_false": "True / False", + "question_type_unknown": "Unknown Type", + + "enter_room_code": "Enter Room Code", + "room_code_hint": "AB123C", + "join_now": "Join Now", + + "create_quiz_title": "Create Quiz", + "save_all": "Save All", + "mode_generate": "Generate", + "mode_manual": "Manual", + + "quiz_play_title": "Answer Quiz", + "ready_in": "Ready in {second}", + "question_indicator": "Question {current} of {total}", + "yes": "Yes", + "no": "No", + "next": "Next", + + "quiz_preview_title": "Preview Quiz", + "quiz_title_label": "Title", + "quiz_description_label": "Short Description", + "quiz_subject_label": "Subject", + "make_quiz_public": "Make Quiz Public", + "save_quiz": "Save Quiz" +} diff --git a/assets/translations/id-ID.json b/assets/translations/id-ID.json new file mode 100644 index 0000000..6bb4eb6 --- /dev/null +++ b/assets/translations/id-ID.json @@ -0,0 +1,83 @@ +{ + "greeting_time": "Selamat Siang", + "greeting_user": "Halo {user}", + "create_room": "Buat Ruangan", + "join_room": "Gabung Ruangan", + "create_quiz": "Buat Kuis", + "ready_new_challenge": "Siap untuk tantangan baru?", + "search_or_select_category": "Cari atau pilih berdasarkan kategori", + "search_for_quizzes": "Cari kuis...", + "quiz_recommendation": "Rekomendasi Kuis", + "log_in": "Masuk", + "sign_in": "Masuk", + "email": "Email", + "enter_your_email": "Masukkan Email Anda", + "password": "Kata Sandi", + "enter_your_password": "Masukkan Kata Sandi Anda", + "or": "ATAU", + "register_title": "Daftar", + "full_name": "Nama Lengkap", + "birth_date": "Tanggal Lahir", + "phone_optional": "Nomor Telepon (Opsional)", + "verify_password": "Verifikasi Kata Sandi", + "register_button": "Daftar", + "nav_home": "Beranda", + "nav_search": "Cari", + "nav_library": "Pustaka", + "nav_history": "Riwayat", + "nav_profile": "Profil", + "quiz_popular": "Kuis Populer", + "see_all": "Lihat Semua", + + "library_title": "Pustaka Kuis", + "library_description": "Kumpulan pertanyaan kuis untuk belajar.", + "no_quiz_available": "Belum ada kuis yang tersedia.", + "quiz_count_label": "Kuis", + "quiz_count_named": "{total} Kuis", + + "history_title": "Riwayat Kuis", + "history_subtitle": "Tinjau kuis yang telah kamu kerjakan", + "no_history": "Kamu belum memiliki riwayat kuis", + "score_label": "Skor: {correct}/{total}", + "duration_minutes": "{minute} menit", + + "edit_profile": "Edit Profil", + "logout": "Keluar", + "total_quiz": "Total Kuis", + "avg_score": "Skor Rata-rata", + + "history_detail_title": "Detail Kuis", + "correct_answer": "Benar", + "score": "Skor", + "time_taken": "Waktu", + "duration_seconds": "{second} detik", + + "your_answer": "Jawaban kamu: {answer}", + "question_type_option": "Pilihan Ganda", + "question_type_fill": "Isian Kosong", + "question_type_true_false": "Benar / Salah", + "question_type_unknown": "Tipe Tidak Dikenal", + + "enter_room_code": "Masukkan Kode Ruangan", + "room_code_hint": "AB123C", + "join_now": "Gabung Sekarang", + + "create_quiz_title": "Buat Kuis", + "save_all": "Simpan Semua", + "mode_generate": "Otomatis", + "mode_manual": "Manual", + + "quiz_play_title": "Kerjakan Kuis", + "ready_in": "Siap dalam {second}", + "question_indicator": "Pertanyaan {current} dari {total}", + "yes": "Ya", + "no": "Tidak", + "next": "Berikutnya", + + "quiz_preview_title": "Pratinjau Kuis", + "quiz_title_label": "Judul", + "quiz_description_label": "Deskripsi Singkat", + "quiz_subject_label": "Mata Pelajaran", + "make_quiz_public": "Jadikan Kuis Publik", + "save_quiz": "Simpan Kuis" +} diff --git a/assets/translations/ms-MY.json b/assets/translations/ms-MY.json new file mode 100644 index 0000000..a5ec604 --- /dev/null +++ b/assets/translations/ms-MY.json @@ -0,0 +1,83 @@ +{ + "greeting_time": "Selamat Tengah Hari", + "greeting_user": "Hai {user}", + "create_room": "Cipta Bilik", + "join_room": "Sertai Bilik", + "create_quiz": "Cipta Kuiz", + "ready_new_challenge": "Bersedia untuk cabaran baharu?", + "search_or_select_category": "Cari atau pilih mengikut kategori", + "search_for_quizzes": "Cari kuiz...", + "quiz_recommendation": "Kuiz Disyorkan", + "log_in": "Log Masuk", + "sign_in": "Daftar Masuk", + "email": "E-mel", + "enter_your_email": "Masukkan E-mel Anda", + "password": "Kata Laluan", + "enter_your_password": "Masukkan Kata Laluan Anda", + "or": "ATAU", + "register_title": "Daftar", + "full_name": "Nama Penuh", + "birth_date": "Tarikh Lahir", + "phone_optional": "Nombor Telefon (Pilihan)", + "verify_password": "Sahkan Kata Laluan", + "register_button": "Daftar", + "nav_home": "Laman Utama", + "nav_search": "Cari", + "nav_library": "Perpustakaan", + "nav_history": "Sejarah", + "nav_profile": "Profil", + "quiz_popular": "Kuiz Popular", + "see_all": "Lihat Semua", + + "library_title": "Perpustakaan Kuiz", + "library_description": "Koleksi soalan kuiz untuk pembelajaran.", + "no_quiz_available": "Tiada kuiz tersedia lagi.", + "quiz_count_label": "Kuiz", + "quiz_count_named": "{total} Kuiz", + + "history_title": "Sejarah Kuiz", + "history_subtitle": "Semak semula kuiz yang telah anda jawab", + "no_history": "Anda belum mempunyai sejarah kuiz", + "score_label": "Skor: {correct}/{total}", + "duration_minutes": "{minute} minit", + + "edit_profile": "Edit Profil", + "logout": "Log Keluar", + "total_quiz": "Jumlah Kuiz", + "avg_score": "Skor Purata", + + "history_detail_title": "Butiran Kuiz", + "correct_answer": "Betul", + "score": "Skor", + "time_taken": "Masa", + "duration_seconds": "{second} saat", + + "your_answer": "Jawapan anda: {answer}", + "question_type_option": "Pilihan Berganda", + "question_type_fill": "Isi Tempat Kosong", + "question_type_true_false": "Betul / Salah", + "question_type_unknown": "Jenis Tidak Diketahui", + + "enter_room_code": "Masukkan Kod Bilik", + "room_code_hint": "AB123C", + "join_now": "Sertai Sekarang", + + "create_quiz_title": "Cipta Kuiz", + "save_all": "Simpan Semua", + "mode_generate": "Jana", + "mode_manual": "Manual", + + "quiz_play_title": "Jawab Kuiz", + "ready_in": "Bersedia dalam {second}", + "question_indicator": "Soalan {current} daripada {total}", + "yes": "Ya", + "no": "Tidak", + "next": "Seterusnya", + + "quiz_preview_title": "Pratonton Kuiz", + "quiz_title_label": "Tajuk", + "quiz_description_label": "Deskripsi Ringkas", + "quiz_subject_label": "Subjek", + "make_quiz_public": "Jadikan Kuiz Umum", + "save_quiz": "Simpan Kuiz" +} diff --git a/lib/app/app.dart b/lib/app/app.dart index 9bcc073..1e707e3 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:get/get_navigation/src/root/get_material_app.dart'; import 'package:quiz_app/app/bindings/initial_bindings.dart'; @@ -9,7 +10,10 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return GetMaterialApp( + localizationsDelegates: context.localizationDelegates, debugShowCheckedModeBanner: false, + supportedLocales: context.supportedLocales, + locale: context.locale, title: 'Quiz App', initialBinding: InitialBindings(), initialRoute: AppRoutes.splashScreen, diff --git a/lib/component/widget/quiz_item_wa_component.dart b/lib/component/widget/quiz_item_wa_component.dart index e6c92ed..a9b4b8e 100644 --- a/lib/component/widget/quiz_item_wa_component.dart +++ b/lib/component/widget/quiz_item_wa_component.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; @@ -54,7 +55,7 @@ class QuizItemWAComponent extends StatelessWidget { const SizedBox(height: 16), if (isOptionType && options != null) _buildOptions(), const SizedBox(height: 12), - _buildAnswerIndicator(), + _buildAnswerIndicator(context), const SizedBox(height: 16), const Divider(height: 24, color: AppColors.shadowPrimary), _buildMetadata(), @@ -109,7 +110,7 @@ class QuizItemWAComponent extends StatelessWidget { ); } - Widget _buildAnswerIndicator() { + Widget _buildAnswerIndicator(BuildContext context) { final icon = isCorrect ? LucideIcons.checkCircle2 : LucideIcons.xCircle; final color = isCorrect ? AppColors.primaryBlue : Colors.red; @@ -124,7 +125,7 @@ class QuizItemWAComponent extends StatelessWidget { Icon(icon, color: color, size: 18), const SizedBox(width: 8), Text( - 'Jawabanmu: $userAnswerText', + context.tr('your_answer', namedArgs: {'answer': userAnswerText}), style: AppTextStyles.statValue, ), ], diff --git a/lib/component/widget/recomendation_component.dart b/lib/component/widget/recomendation_component.dart index 8d73c25..c250a6a 100644 --- a/lib/component/widget/recomendation_component.dart +++ b/lib/component/widget/recomendation_component.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:quiz_app/component/quiz_container_component.dart'; import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; @@ -21,7 +22,7 @@ class RecomendationComponent extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildSectionTitle(title), + _buildSectionTitle(context, title), const SizedBox(height: 10), datas.isNotEmpty // ? Text("yeay ${datas.length}") @@ -53,7 +54,7 @@ class RecomendationComponent extends StatelessWidget { // ); // } - Widget _buildSectionTitle(String title) { + Widget _buildSectionTitle(BuildContext context, String title) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: Row( @@ -66,7 +67,7 @@ class RecomendationComponent extends StatelessWidget { GestureDetector( onTap: allOnTap, child: Text( - "Lihat semua", + context.tr('see_all'), style: TextStyle(fontSize: 14, color: Colors.blue.shade700), ), ), diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart index d518cfb..2188098 100644 --- a/lib/core/endpoint/api_endpoint.dart +++ b/lib/core/endpoint/api_endpoint.dart @@ -1,6 +1,6 @@ class APIEndpoint { - // static const String baseUrl = "http://192.168.1.9:5000"; - static const String baseUrl = "http://172.16.106.133:5000"; + static const String baseUrl = "http://192.168.1.9:5000"; + // static const String baseUrl = "http://172.16.106.133:5000"; static const String api = "$baseUrl/api"; static const String login = "/login"; diff --git a/lib/feature/detail_quiz/view/detail_quix_view.dart b/lib/feature/detail_quiz/view/detail_quix_view.dart index d2e3afd..5042b96 100644 --- a/lib/feature/detail_quiz/view/detail_quix_view.dart +++ b/lib/feature/detail_quiz/view/detail_quix_view.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; @@ -81,6 +82,7 @@ class DetailQuizView extends GetView { const SizedBox(height: 20), const Divider(thickness: 1.2, color: AppColors.borderLight), const SizedBox(height: 20), + // Soal Section ListView.builder( shrinkWrap: true, @@ -169,13 +171,13 @@ class DetailQuizView extends GetView { String _mapQuestionTypeToText(String? type) { switch (type) { case 'option': - return 'Pilihan Ganda'; + return tr('question_type_option'); case 'fill_the_blank': - return 'Isian Kosong'; + return tr('question_type_fill'); case 'true_false': - return 'Benar / Salah'; + return tr('question_type_true_false'); default: - return 'Tipe Tidak Diketahui'; + return tr('question_type_unknown'); } } } diff --git a/lib/feature/history/view/detail_history_view.dart b/lib/feature/history/view/detail_history_view.dart index 5b1b217..413ca42 100644 --- a/lib/feature/history/view/detail_history_view.dart +++ b/lib/feature/history/view/detail_history_view.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:get/get_state_manager/get_state_manager.dart'; import 'package:lucide_icons/lucide_icons.dart'; @@ -18,7 +19,7 @@ class DetailHistoryView extends GetView { backgroundColor: AppColors.background, elevation: 0, title: Text( - 'Detail history', + context.tr('history_detail_title'), style: AppTextStyles.title.copyWith(fontSize: 24), ), centerTitle: true, @@ -31,7 +32,7 @@ class DetailHistoryView extends GetView { } return ListView( children: [ - quizMetaInfo(), + quizMetaInfo(context), ...quizListings(), ], ); @@ -55,7 +56,7 @@ class DetailHistoryView extends GetView { .toList(); } - Widget quizMetaInfo() { + Widget quizMetaInfo(BuildContext context) { final quiz = controller.quizAnswer; return Container( @@ -106,20 +107,20 @@ class DetailHistoryView extends GetView { children: [ _buildStatItem( icon: LucideIcons.checkCircle2, - label: 'Benar', + label: context.tr('correct_answer'), value: "${quiz.totalCorrect}/${quiz.questionListings.length}", color: Colors.green, ), _buildStatItem( icon: LucideIcons.award, - label: 'Skor', + label: context.tr('score'), value: quiz.totalScore.toString(), color: Colors.blueAccent, ), _buildStatItem( icon: LucideIcons.clock3, - label: 'Waktu', - value: '${quiz.totalSolveTime}s', + label: context.tr('time_taken'), + value: tr('duration_seconds', namedArgs: {"second": quiz.totalSolveTime.toString()}), color: Colors.orange, ), ], diff --git a/lib/feature/history/view/history_view.dart b/lib/feature/history/view/history_view.dart index 1582446..cd41db1 100644 --- a/lib/feature/history/view/history_view.dart +++ b/lib/feature/history/view/history_view.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; @@ -19,10 +20,10 @@ class HistoryView extends GetView { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text("Riwayat Kuis", style: AppTextStyles.title.copyWith(fontSize: 24)), + Text(context.tr("history_title"), style: AppTextStyles.title.copyWith(fontSize: 24)), const SizedBox(height: 8), Text( - "Lihat kembali hasil kuis yang telah kamu kerjakan", + context.tr("history_subtitle"), style: AppTextStyles.subtitle, ), const SizedBox(height: 20), @@ -38,7 +39,7 @@ class HistoryView extends GetView { if (historyList.isEmpty) { return Expanded( child: Center( - child: Text("You don't have any quiz history yet", style: AppTextStyles.body), + child: Text(context.tr("no_history"), style: AppTextStyles.body), ), ); } @@ -102,13 +103,13 @@ class HistoryView extends GetView { const Icon(Icons.check_circle, size: 14, color: Colors.green), const SizedBox(width: 4), Text( - "Skor: ${item.totalCorrect}/${item.totalQuestion}", + tr('score_label', namedArgs: {'correct': item.totalCorrect.toString(), 'total': item.totalQuestion.toString()}), style: AppTextStyles.caption, ), const SizedBox(width: 16), const Icon(Icons.timer, size: 14, color: Colors.grey), const SizedBox(width: 4), - Text("3 menit", style: AppTextStyles.caption), + Text(tr("duration_minutes", namedArgs: {"minute": "3"}), style: AppTextStyles.caption), ], ), ], diff --git a/lib/feature/home/view/component/button_option.dart b/lib/feature/home/view/component/button_option.dart index f42006c..acd579e 100644 --- a/lib/feature/home/view/component/button_option.dart +++ b/lib/feature/home/view/component/button_option.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; class ButtonOption extends StatelessWidget { @@ -19,22 +20,22 @@ class ButtonOption extends StatelessWidget { margin: const EdgeInsets.symmetric(vertical: 5, horizontal: 20), child: Row( children: [ - Expanded(child: _buildCreateButton()), + Expanded(child: _buildCreateButton(context)), const SizedBox(width: 12), - Expanded(child: _buildRoomButtons()), + Expanded(child: _buildRoomButtons(context)), ], ), ); } - Widget _buildCreateButton() { + Widget _buildCreateButton(BuildContext context) { return InkWell( onTap: onCreate, borderRadius: BorderRadius.circular(16), child: SizedBox( height: double.infinity, child: _buildButtonContainer( - label: 'Buat Quiz', + label: context.tr("create_quiz"), gradientColors: [Color(0xFF0052CC), Color(0xFF0367D3)], icon: Icons.create, ), @@ -42,7 +43,7 @@ class ButtonOption extends StatelessWidget { ); } - Widget _buildRoomButtons() { + Widget _buildRoomButtons(BuildContext context) { return Column( children: [ Expanded( @@ -50,7 +51,7 @@ class ButtonOption extends StatelessWidget { onTap: onCreateRoom, borderRadius: BorderRadius.circular(16), child: _buildButtonContainer( - label: 'Buat Room', + label: context.tr("create_room"), gradientColors: [Color(0xFF36B37E), Color(0xFF22C39F)], icon: Icons.meeting_room, ), @@ -62,7 +63,7 @@ class ButtonOption extends StatelessWidget { onTap: onJoinRoom, borderRadius: BorderRadius.circular(16), child: _buildButtonContainer( - label: 'Join Room', + label: context.tr("join_room"), gradientColors: [Color(0xFFFFAB00), Color(0xFFFFC107)], icon: Icons.group, ), diff --git a/lib/feature/home/view/component/search_component.dart b/lib/feature/home/view/component/search_component.dart index c02235e..653560d 100644 --- a/lib/feature/home/view/component/search_component.dart +++ b/lib/feature/home/view/component/search_component.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; import 'package:quiz_app/data/models/subject/subject_model.dart'; @@ -27,22 +28,22 @@ class SearchComponent extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildTitleSection(), + _buildTitleSection(context), const SizedBox(height: 12), _buildCategoryRow(), const SizedBox(height: 12), - _buildSearchInput(), + _buildSearchInput(context), ], ), ); } - Widget _buildTitleSection() { + Widget _buildTitleSection(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, - children: const [ + children: [ Text( - "Ready for a new challenge?", + context.tr("ready_new_challenge"), style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, @@ -51,7 +52,7 @@ class SearchComponent extends StatelessWidget { ), SizedBox(height: 5), Text( - "Search or select by category", + context.tr("search_or_select_category"), style: TextStyle( fontSize: 14, color: Color(0xFF6B778C), // Soft gray text @@ -98,7 +99,7 @@ class SearchComponent extends StatelessWidget { ); } - Widget _buildSearchInput() { + Widget _buildSearchInput(BuildContext context) { return GestureDetector( onTap: () => onSearchTap(), child: Container( @@ -115,11 +116,11 @@ class SearchComponent extends StatelessWidget { ], ), child: Row( - children: const [ + children: [ Icon(Icons.search, color: Color(0xFF6B778C)), SizedBox(width: 8), Text( - "Search for quizzes...", + context.tr("search_for_quizzes"), style: TextStyle( color: Color(0xFF6B778C), fontSize: 16, diff --git a/lib/feature/home/view/component/user_gretings.dart b/lib/feature/home/view/component/user_gretings.dart index 8a3ee05..7b55af5 100644 --- a/lib/feature/home/view/component/user_gretings.dart +++ b/lib/feature/home/view/component/user_gretings.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; class UserGretingsComponent extends StatelessWidget { @@ -34,11 +35,11 @@ class UserGretingsComponent extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - "Selamat Siang", + context.tr("greeting_time"), style: TextStyle(fontWeight: FontWeight.bold), ), Text( - "Hello $userName", + context.tr("greeting_user", namedArgs: {"user": userName}), style: TextStyle(fontWeight: FontWeight.w500), ), ], diff --git a/lib/feature/home/view/home_page.dart b/lib/feature/home/view/home_page.dart index caab0ec..d815de6 100644 --- a/lib/feature/home/view/home_page.dart +++ b/lib/feature/home/view/home_page.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; @@ -50,7 +51,7 @@ class HomeView extends GetView { const SizedBox(height: 20), Obx( () => RecomendationComponent( - title: "Quiz Rekomendasi", + title: context.tr("quiz_recommendation"), datas: controller.data.toList(), itemOnTap: controller.onRecommendationTap, allOnTap: () => controller.goToListingsQuizPage(ListingType.recomendation), diff --git a/lib/feature/join_room/view/join_room_view.dart b/lib/feature/join_room/view/join_room_view.dart index 8e8c860..663529a 100644 --- a/lib/feature/join_room/view/join_room_view.dart +++ b/lib/feature/join_room/view/join_room_view.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:get/get.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; import 'package:quiz_app/component/global_button.dart'; import 'package:quiz_app/component/global_text_field.dart'; @@ -41,9 +41,9 @@ class JoinRoomView extends GetView { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - "Masukkan Kode Room", - style: TextStyle( + Text( + context.tr("enter_room_code"), + style: const TextStyle( fontSize: 20, fontWeight: FontWeight.w700, color: Colors.black87, @@ -52,18 +52,12 @@ class JoinRoomView extends GetView { const SizedBox(height: 16), GlobalTextField( controller: controller.codeController, - hintText: "AB123C", + hintText: context.tr("room_code_hint"), textInputType: TextInputType.text, - // Uncomment if needed: - // maxLength: 6, - // inputFormatters: [ - // FilteringTextInputFormatter.allow(RegExp(r'[A-Z0-9]')), - // UpperCaseTextFormatter(), - // ], ), const SizedBox(height: 30), GlobalButton( - text: "Gabung Sekarang", + text: context.tr("join_now"), onPressed: controller.joinRoom, ), ], diff --git a/lib/feature/library/view/library_view.dart b/lib/feature/library/view/library_view.dart index 74dc05a..a583c9b 100644 --- a/lib/feature/library/view/library_view.dart +++ b/lib/feature/library/view/library_view.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:quiz_app/component/widget/loading_widget.dart'; import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; import 'package:quiz_app/feature/library/controller/library_controller.dart'; @@ -17,18 +18,18 @@ class LibraryView extends GetView { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Library Soal', - style: TextStyle( + Text( + context.tr('library_title'), + style: const TextStyle( color: Colors.black, fontWeight: FontWeight.bold, fontSize: 24, ), ), const SizedBox(height: 8), - const Text( - "Kumpulan soal-soal kuis yang sudah dibuat untuk dipelajari.", - style: TextStyle( + Text( + context.tr('library_description'), + style: const TextStyle( color: Colors.grey, fontSize: 14, ), @@ -41,10 +42,10 @@ class LibraryView extends GetView { } if (controller.quizs.isEmpty) { - return const Center( + return Center( child: Text( - "Belum ada soal tersedia.", - style: TextStyle(color: Colors.grey, fontSize: 14), + context.tr('no_quiz_available'), + style: const TextStyle(color: Colors.grey, fontSize: 14), ), ); } @@ -53,7 +54,10 @@ class LibraryView extends GetView { itemCount: controller.quizs.length, itemBuilder: (context, index) { final quiz = controller.quizs[index]; - return InkWell(onTap: () => controller.goToDetail(index), child: _buildQuizCard(quiz)); + return InkWell( + onTap: () => controller.goToDetail(index), + child: _buildQuizCard(context, quiz), + ); }, ); }), @@ -65,7 +69,7 @@ class LibraryView extends GetView { ); } - Widget _buildQuizCard(QuizListingModel quiz) { + Widget _buildQuizCard(BuildContext context, QuizListingModel quiz) { return Container( margin: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.all(16), @@ -74,7 +78,7 @@ class LibraryView extends GetView { borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.05), + color: Colors.black.withOpacity(0.05), blurRadius: 6, offset: const Offset(0, 2), ), @@ -129,7 +133,7 @@ class LibraryView extends GetView { const Icon(Icons.list, size: 14, color: Colors.grey), const SizedBox(width: 4), Text( - '${quiz.totalQuiz} Quizzes', + context.tr('quiz_count_named', namedArgs: {'total': quiz.totalQuiz.toString()}), style: const TextStyle(fontSize: 12, color: Colors.grey), ), const SizedBox(width: 12), diff --git a/lib/feature/listing_quiz/view/listing_quiz_view.dart b/lib/feature/listing_quiz/view/listing_quiz_view.dart index 270e402..e4f0b8a 100644 --- a/lib/feature/listing_quiz/view/listing_quiz_view.dart +++ b/lib/feature/listing_quiz/view/listing_quiz_view.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; @@ -27,7 +28,12 @@ class ListingsQuizView extends GetView { } if (controller.quizzes.isEmpty) { - return const Center(child: Text('Tidak ada kuis tersedia.')); + return Center( + child: Text( + context.tr('no_quiz_available'), + textAlign: TextAlign.center, + ), + ); } return ListView.builder( diff --git a/lib/feature/login/view/login_page.dart b/lib/feature/login/view/login_page.dart index 85d7258..74f8f0f 100644 --- a/lib/feature/login/view/login_page.dart +++ b/lib/feature/login/view/login_page.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; @@ -24,26 +25,26 @@ class LoginView extends GetView { const SizedBox(height: 40), const AppName(), const SizedBox(height: 40), - const LabelTextField( - label: "Log In", + LabelTextField( + label: context.tr("log_in"), fontSize: 28, fontWeight: FontWeight.bold, color: Color(0xFF172B4D), ), const SizedBox(height: 24), - const LabelTextField( - label: "Email", + LabelTextField( + label: context.tr("email"), color: Color(0xFF6B778C), fontSize: 14, ), const SizedBox(height: 6), GlobalTextField( controller: controller.emailController, - hintText: "Masukkan email anda", + hintText: context.tr("enter_your_email"), ), const SizedBox(height: 20), - const LabelTextField( - label: "Password", + LabelTextField( + label: context.tr("password"), color: Color(0xFF6B778C), fontSize: 14, ), @@ -54,18 +55,18 @@ class LoginView extends GetView { isPassword: true, obscureText: controller.isPasswordHidden.value, onToggleVisibility: controller.togglePasswordVisibility, - hintText: "Masukkan password anda", + hintText: context.tr("enter_your_password"), ), ), const SizedBox(height: 32), Obx(() => GlobalButton( onPressed: controller.loginWithEmail, - text: "Masuk", + text: context.tr("sign_in"), type: controller.isButtonEnabled.value, )), const SizedBox(height: 24), - const LabelTextField( - label: "OR", + LabelTextField( + label: context.tr("or"), alignment: Alignment.center, color: Color(0xFF6B778C), ), diff --git a/lib/feature/navigation/views/navbar_view.dart b/lib/feature/navigation/views/navbar_view.dart index 2f52203..2f0f781 100644 --- a/lib/feature/navigation/views/navbar_view.dart +++ b/lib/feature/navigation/views/navbar_view.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/feature/history/view/history_view.dart'; @@ -31,29 +32,29 @@ class NavbarView extends GetView { }), bottomNavigationBar: Obx( () => BottomNavigationBar( - type: BottomNavigationBarType.fixed, // <=== ini tambahan penting! + type: BottomNavigationBarType.fixed, currentIndex: controller.selectedIndex.value, onTap: controller.changePage, - items: const [ + items: [ BottomNavigationBarItem( - icon: Icon(Icons.home), - label: 'Home', + icon: const Icon(Icons.home), + label: context.tr('nav_home'), ), BottomNavigationBarItem( - icon: Icon(Icons.search), - label: 'Search', + icon: const Icon(Icons.search), + label: context.tr('nav_search'), ), BottomNavigationBarItem( - icon: Icon(Icons.menu_book), - label: 'Library', + icon: const Icon(Icons.menu_book), + label: context.tr('nav_library'), ), BottomNavigationBarItem( - icon: Icon(Icons.history), - label: 'History', + icon: const Icon(Icons.history), + label: context.tr('nav_history'), ), BottomNavigationBarItem( - icon: Icon(Icons.person), - label: 'Profile', + icon: const Icon(Icons.person), + label: context.tr('nav_profile'), ), ], ), diff --git a/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart b/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart index 9055503..c4761fd 100644 --- a/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart +++ b/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart @@ -54,7 +54,6 @@ class PlayQuizMultiplayerView extends GetView { if (question.type == 'option') _buildOptionQuestion(), if (question.type == 'fill_in_the_blank') _buildFillInBlankQuestion(), if (question.type == 'true_false') _buildTrueFalseQuestion(), - const Spacer(), SizedBox( width: double.infinity, diff --git a/lib/feature/profile/view/profile_view.dart b/lib/feature/profile/view/profile_view.dart index da67a4d..748bc70 100644 --- a/lib/feature/profile/view/profile_view.dart +++ b/lib/feature/profile/view/profile_view.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/feature/profile/controller/profile_controller.dart'; @@ -28,11 +29,20 @@ class ProfileView extends GetView { style: const TextStyle(fontSize: 14, color: Colors.grey), ), const SizedBox(height: 24), - _buildStats(), + _buildStats(context), const SizedBox(height: 32), - _buildActionButton("Edit Profil", Icons.edit, controller.editProfile), + _buildActionButton( + context.tr("edit_profile"), + Icons.edit, + controller.editProfile, + ), const SizedBox(height: 12), - _buildActionButton("Logout", Icons.logout, controller.logout, isDestructive: true), + _buildActionButton( + context.tr("logout"), + Icons.logout, + controller.logout, + isDestructive: true, + ), ], ); }), @@ -57,7 +67,7 @@ class ProfileView extends GetView { } } - Widget _buildStats() { + Widget _buildStats(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), decoration: BoxDecoration( @@ -74,9 +84,15 @@ class ProfileView extends GetView { child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - _buildStatItem("Total Quiz", controller.totalQuizzes.value.toString()), + _buildStatItem( + context.tr("total_quiz"), + controller.totalQuizzes.value.toString(), + ), const SizedBox(width: 16), - _buildStatItem("Skor Rata-rata", "${controller.avgScore.value}%"), + _buildStatItem( + context.tr("avg_score"), + "${controller.avgScore.value}%", + ), ], ), ); diff --git a/lib/feature/quiz_creation/view/quiz_creation_view.dart b/lib/feature/quiz_creation/view/quiz_creation_view.dart index f99f694..53dcefd 100644 --- a/lib/feature/quiz_creation/view/quiz_creation_view.dart +++ b/lib/feature/quiz_creation/view/quiz_creation_view.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; import 'package:quiz_app/component/global_button.dart'; import 'package:quiz_app/feature/quiz_creation/controller/quiz_creation_controller.dart'; @@ -16,9 +17,9 @@ class QuizCreationView extends GetView { appBar: AppBar( backgroundColor: AppColors.background, elevation: 0, - title: const Text( - 'Create Quiz', - style: TextStyle( + title: Text( + context.tr('create_quiz_title'), + style: const TextStyle( fontWeight: FontWeight.bold, color: AppColors.darkText, ), @@ -36,13 +37,14 @@ class QuizCreationView extends GetView { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildModeSelector(), + _buildModeSelector(context), const SizedBox(height: 20), - Obx( - () => controller.isGenerate.value ? GenerateComponent() : CustomQuestionComponent(), - ), + Obx(() => controller.isGenerate.value ? const GenerateComponent() : const CustomQuestionComponent()), const SizedBox(height: 30), - GlobalButton(text: "simpan semua", onPressed: controller.onDone) + GlobalButton( + text: context.tr('save_all'), + onPressed: controller.onDone, + ) ], ), ), @@ -51,7 +53,7 @@ class QuizCreationView extends GetView { ); } - Widget _buildModeSelector() { + Widget _buildModeSelector(BuildContext context) { return Container( decoration: BoxDecoration( color: AppColors.background, @@ -60,8 +62,8 @@ class QuizCreationView extends GetView { ), child: Row( children: [ - _buildModeButton('Generate', controller.isGenerate, true), - _buildModeButton('Manual', controller.isGenerate, false), + _buildModeButton(context.tr('mode_generate'), controller.isGenerate, true), + _buildModeButton(context.tr('mode_manual'), controller.isGenerate, false), ], ), ); @@ -71,17 +73,18 @@ class QuizCreationView extends GetView { return Expanded( child: InkWell( onTap: () => controller.onCreationTypeChange(base), - child: Obx( - () => Container( + child: Obx(() { + final selected = isSelected.value == base; + return Container( padding: const EdgeInsets.symmetric(vertical: 14), decoration: BoxDecoration( - color: isSelected.value == base ? AppColors.primaryBlue : Colors.transparent, + color: selected ? AppColors.primaryBlue : Colors.transparent, borderRadius: base - ? BorderRadius.only( + ? const BorderRadius.only( topLeft: Radius.circular(10), bottomLeft: Radius.circular(10), ) - : BorderRadius.only( + : const BorderRadius.only( topRight: Radius.circular(10), bottomRight: Radius.circular(10), ), @@ -90,12 +93,12 @@ class QuizCreationView extends GetView { child: Text( label, style: TextStyle( - color: isSelected.value == base ? Colors.white : AppColors.softGrayText, + color: selected ? Colors.white : AppColors.softGrayText, fontWeight: FontWeight.w600, ), ), - ), - ), + ); + }), ), ); } diff --git a/lib/feature/quiz_play/view/quiz_play_view.dart b/lib/feature/quiz_play/view/quiz_play_view.dart index 5fc9989..929f277 100644 --- a/lib/feature/quiz_play/view/quiz_play_view.dart +++ b/lib/feature/quiz_play/view/quiz_play_view.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; @@ -18,27 +19,26 @@ class QuizPlayView extends GetView { padding: const EdgeInsets.all(16), child: Obx(() { if (!controller.isStarting.value) { - return Center( - child: Text( - "Ready in ${controller.prepareDuration}", - style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), - )); + return Text( + context.tr('ready_in', namedArgs: {'second': controller.prepareDuration.toString()}), + style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildCustomAppBar(), + _buildCustomAppBar(context), const SizedBox(height: 20), _buildProgressBar(), const SizedBox(height: 20), - _buildQuestionIndicator(), + _buildQuestionIndicator(context), const SizedBox(height: 12), _buildQuestionText(), const SizedBox(height: 30), - _buildAnswerSection(), + _buildAnswerSection(context), const Spacer(), - _buildNextButton(), + _buildNextButton(context), ], ); }), @@ -47,7 +47,7 @@ class QuizPlayView extends GetView { ); } - Widget _buildCustomAppBar() { + Widget _buildCustomAppBar(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: const BoxDecoration( @@ -55,9 +55,9 @@ class QuizPlayView extends GetView { ), child: Row( mainAxisAlignment: MainAxisAlignment.center, - children: const [ + children: [ Text( - 'Kerjakan Soal', + context.tr('quiz_play_title'), style: TextStyle( color: Colors.black, fontWeight: FontWeight.bold, @@ -79,9 +79,15 @@ class QuizPlayView extends GetView { ); } - Widget _buildQuestionIndicator() { + Widget _buildQuestionIndicator(BuildContext context) { return Text( - 'Soal ${controller.currentIndex.value + 1} dari ${controller.quizData.questionListings.length}', + context.tr( + 'question_indicator', + namedArgs: { + 'current': (controller.currentIndex.value + 1).toString(), + 'total': controller.quizData.questionListings.length.toString(), + }, + ), style: const TextStyle( fontSize: 16, color: Colors.grey, @@ -101,7 +107,7 @@ class QuizPlayView extends GetView { ); } - Widget _buildAnswerSection() { + Widget _buildAnswerSection(BuildContext context) { final question = controller.currentQuestion; if (question is OptionQuestion) { @@ -131,8 +137,8 @@ class QuizPlayView extends GetView { return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - _buildTrueFalseButton('Ya', true, controller.choosenAnswerTOF), - _buildTrueFalseButton('Tidak', false, controller.choosenAnswerTOF), + _buildTrueFalseButton(context.tr('yes'), true, controller.choosenAnswerTOF), + _buildTrueFalseButton(context.tr('no'), false, controller.choosenAnswerTOF), ], ); } else { @@ -159,7 +165,7 @@ class QuizPlayView extends GetView { }); } - Widget _buildNextButton() { + Widget _buildNextButton(BuildContext context) { return Obx(() { final isEnabled = controller.isAnswerSelected.value; @@ -171,8 +177,8 @@ class QuizPlayView extends GetView { minimumSize: const Size(double.infinity, 50), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), - child: const Text( - 'Next', + child: Text( + context.tr('next'), style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), ); diff --git a/lib/feature/quiz_preview/view/quiz_preview.dart b/lib/feature/quiz_preview/view/quiz_preview.dart index 8b5209c..668263c 100644 --- a/lib/feature/quiz_preview/view/quiz_preview.dart +++ b/lib/feature/quiz_preview/view/quiz_preview.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; import 'package:quiz_app/component/global_button.dart'; import 'package:quiz_app/component/global_text_field.dart'; @@ -15,25 +16,25 @@ class QuizPreviewPage extends GetView { Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.background, - appBar: _buildAppBar(), + appBar: _buildAppBar(context), body: SafeArea( child: Padding( padding: const EdgeInsets.all(20.0), - child: _buildContent(), + child: _buildContent(context), ), ), ); } - PreferredSizeWidget _buildAppBar() { + PreferredSizeWidget _buildAppBar(BuildContext context) { return AppBar( backgroundColor: AppColors.background, elevation: 0, centerTitle: true, iconTheme: const IconThemeData(color: AppColors.darkText), - title: const Text( - 'Preview Quiz', - style: TextStyle( + title: Text( + context.tr('quiz_preview_title'), + style: const TextStyle( color: AppColors.darkText, fontWeight: FontWeight.bold, ), @@ -41,25 +42,25 @@ class QuizPreviewPage extends GetView { ); } - Widget _buildContent() { + Widget _buildContent(BuildContext context) { return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const LabelTextField(label: "Judul"), + LabelTextField(label: context.tr("quiz_title_label")), GlobalTextField(controller: controller.titleController), const SizedBox(height: 20), - const LabelTextField(label: "Deskripsi Singkat"), + LabelTextField(label: context.tr("quiz_description_label")), GlobalTextField(controller: controller.descriptionController), const SizedBox(height: 20), - const LabelTextField(label: "Mata Pelajaran"), + LabelTextField(label: context.tr("quiz_subject_label")), Obx(() => SubjectDropdownComponent( data: controller.subjects.toList(), onItemTap: controller.onSubjectTap, selectedIndex: controller.subjectIndex.value, )), const SizedBox(height: 20), - _buildPublicCheckbox(), + _buildPublicCheckbox(context), const SizedBox(height: 30), const Divider(thickness: 1.2, color: AppColors.borderLight), const SizedBox(height: 20), @@ -67,7 +68,7 @@ class QuizPreviewPage extends GetView { const SizedBox(height: 30), GlobalButton( onPressed: controller.onSaveQuiz, - text: "Simpan Kuis", + text: context.tr("save_quiz"), ), ], ), @@ -83,7 +84,7 @@ class QuizPreviewPage extends GetView { ); } - Widget _buildPublicCheckbox() { + Widget _buildPublicCheckbox(BuildContext context) { return Obx(() => GestureDetector( onTap: controller.isPublic.toggle, child: Row( @@ -96,9 +97,9 @@ class QuizPreviewPage extends GetView { onChanged: (val) => controller.isPublic.value = val ?? false, ), const SizedBox(width: 8), - const Text( - "Buat Kuis Public", - style: TextStyle( + Text( + context.tr("make_quiz_public"), + style: const TextStyle( fontSize: 16, color: AppColors.darkText, fontWeight: FontWeight.w500, diff --git a/lib/feature/register/view/register_page.dart b/lib/feature/register/view/register_page.dart index e36cbdd..6a72f87 100644 --- a/lib/feature/register/view/register_page.dart +++ b/lib/feature/register/view/register_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; import 'package:quiz_app/component/app_name.dart'; import 'package:quiz_app/component/global_button.dart'; @@ -9,6 +10,7 @@ import 'package:quiz_app/feature/register/controller/register_controller.dart'; class RegisterView extends GetView { const RegisterView({super.key}); + @override Widget build(BuildContext context) { return Scaffold( @@ -22,47 +24,52 @@ class RegisterView extends GetView { padding: const EdgeInsets.symmetric(vertical: 40), child: AppName(), ), - LabelTextField(label: "Register", fontSize: 24), + LabelTextField( + label: context.tr('register_title'), + fontSize: 24, + ), const SizedBox(height: 10), - LabelTextField(label: "Full Name"), + LabelTextField(label: context.tr('full_name')), GlobalTextField(controller: controller.nameController), const SizedBox(height: 10), - LabelTextField(label: "Email"), + LabelTextField(label: context.tr('email')), GlobalTextField(controller: controller.emailController), const SizedBox(height: 10), - LabelTextField(label: "Birth Date"), + LabelTextField(label: context.tr('birth_date')), GlobalTextField( controller: controller.bDateController, hintText: "12-08-2001", ), - LabelTextField(label: "Nomer Telepon (Opsional)"), + LabelTextField(label: context.tr('phone_optional')), GlobalTextField( controller: controller.phoneController, hintText: "085708570857", ), const SizedBox(height: 10), - LabelTextField(label: "Password"), + LabelTextField(label: context.tr('password')), Obx( () => GlobalTextField( - controller: controller.passwordController, - isPassword: true, - obscureText: controller.isPasswordHidden.value, - onToggleVisibility: controller.togglePasswordVisibility), + controller: controller.passwordController, + isPassword: true, + obscureText: controller.isPasswordHidden.value, + onToggleVisibility: controller.togglePasswordVisibility, + ), ), const SizedBox(height: 10), - LabelTextField(label: "Verify Password"), + LabelTextField(label: context.tr('verify_password')), Obx( () => GlobalTextField( - controller: controller.confirmPasswordController, - isPassword: true, - obscureText: controller.isConfirmPasswordHidden.value, - onToggleVisibility: controller.toggleConfirmPasswordVisibility), + controller: controller.confirmPasswordController, + isPassword: true, + obscureText: controller.isConfirmPasswordHidden.value, + onToggleVisibility: controller.toggleConfirmPasswordVisibility, + ), ), const SizedBox(height: 40), GlobalButton( onPressed: controller.onRegister, - text: "Register", - ) + text: context.tr('register_button'), + ), ], ), ), diff --git a/lib/feature/search/view/search_view.dart b/lib/feature/search/view/search_view.dart index 43a9521..ba4856d 100644 --- a/lib/feature/search/view/search_view.dart +++ b/lib/feature/search/view/search_view.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/const/enums/listing_type.dart'; @@ -31,7 +32,7 @@ class SearchView extends GetView { ] else ...[ Obx( () => RecomendationComponent( - title: "Quiz Rekomendasi", + title: context.tr('quiz_recommendation'), datas: controller.recommendationQData.toList(), itemOnTap: controller.goToDetailPage, allOnTap: () => controller.goToListingsQuizPage(ListingType.recomendation), @@ -40,7 +41,7 @@ class SearchView extends GetView { const SizedBox(height: 30), Obx( () => RecomendationComponent( - title: "Quiz Populer", + title: context.tr('quiz_popular'), datas: controller.recommendationQData.toList(), itemOnTap: controller.goToDetailPage, allOnTap: () => controller.goToListingsQuizPage(ListingType.populer), diff --git a/lib/main.dart b/lib/main.dart index c62a826..1ecc580 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:quiz_app/app/app.dart'; @@ -14,7 +15,21 @@ void main() { DeviceOrientation.portraitDown, ]); - runApp(MyApp()); + WidgetsFlutterBinding.ensureInitialized(); + + await EasyLocalization.ensureInitialized(); + + runApp( + EasyLocalization( + supportedLocales: [ + Locale('en', 'US'), + Locale('id', 'ID'), + ], + path: 'assets/translations', + fallbackLocale: Locale('en', 'US'), + child: MyApp(), + ), + ); }, (e, stackTrace) { logC.e("issue message $e || $stackTrace"); }); diff --git a/pubspec.lock b/pubspec.lock index f41ffd9..2e87f83 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -73,6 +81,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + easy_localization: + dependency: "direct main" + description: + name: easy_localization + sha256: "0f5239c7b8ab06c66440cfb0e9aa4b4640429c6668d5a42fe389c5de42220b12" + url: "https://pub.dev" + source: hosted + version: "3.0.7+1" + easy_logger: + dependency: transitive + description: + name: easy_logger + sha256: c764a6e024846f33405a2342caf91c62e357c24b02c04dbc712ef232bf30ffb7 + url: "https://pub.dev" + source: hosted + version: "0.0.2" fake_async: dependency: transitive description: @@ -118,6 +142,11 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_test: dependency: "direct dev" description: flutter @@ -208,6 +237,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" leak_tracker: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f304327..823951f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -43,6 +43,7 @@ dependencies: lucide_icons: ^0.257.0 google_fonts: ^6.1.0 socket_io_client: ^3.1.2 + easy_localization: ^3.0.7+1 dev_dependencies: flutter_test: @@ -69,6 +70,7 @@ flutter: assets: - assets/ - assets/logo/ + - assets/translations/ # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg From da6597f42b20ee7917dca658849bdadf67a4537a Mon Sep 17 00:00:00 2001 From: akhdanre Date: Wed, 7 May 2025 22:26:49 +0700 Subject: [PATCH 056/104] feat: change language --- assets/translations/en-US.json | 5 ++- assets/translations/id-ID.json | 7 ++-- lib/app/app.dart | 10 +++--- .../controller/profile_controller.dart | 9 +++++ lib/feature/profile/view/profile_view.dart | 36 +++++++++++++++++++ lib/main.dart | 3 +- 6 files changed, 61 insertions(+), 9 deletions(-) diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index 6e6f10c..b239b4b 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -79,5 +79,8 @@ "quiz_description_label": "Short Description", "quiz_subject_label": "Subject", "make_quiz_public": "Make Quiz Public", - "save_quiz": "Save Quiz" + "save_quiz": "Save Quiz", + + "select_language": "Select Language", + "change_language": "Change Language" } diff --git a/assets/translations/id-ID.json b/assets/translations/id-ID.json index 6bb4eb6..345ee7d 100644 --- a/assets/translations/id-ID.json +++ b/assets/translations/id-ID.json @@ -2,7 +2,7 @@ "greeting_time": "Selamat Siang", "greeting_user": "Halo {user}", "create_room": "Buat Ruangan", - "join_room": "Gabung Ruangan", + "join_room": "Gabung Ruang", "create_quiz": "Buat Kuis", "ready_new_challenge": "Siap untuk tantangan baru?", "search_or_select_category": "Cari atau pilih berdasarkan kategori", @@ -79,5 +79,8 @@ "quiz_description_label": "Deskripsi Singkat", "quiz_subject_label": "Mata Pelajaran", "make_quiz_public": "Jadikan Kuis Publik", - "save_quiz": "Simpan Kuis" + "save_quiz": "Simpan Kuis", + + "select_language": "Pilih Bahasa", + "change_language": "Ganti Bahasa" } diff --git a/lib/app/app.dart b/lib/app/app.dart index 1e707e3..9c22969 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,6 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:get/get_navigation/src/root/get_material_app.dart'; +import 'package:get/get.dart'; import 'package:quiz_app/app/bindings/initial_bindings.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; @@ -10,11 +10,11 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return GetMaterialApp( - localizationsDelegates: context.localizationDelegates, - debugShowCheckedModeBanner: false, - supportedLocales: context.supportedLocales, - locale: context.locale, title: 'Quiz App', + locale: Get.locale ?? context.locale, // 🔁 This ensures GetX reacts to locale changes + fallbackLocale: const Locale('en', 'US'), + localizationsDelegates: context.localizationDelegates, + supportedLocales: context.supportedLocales, initialBinding: InitialBindings(), initialRoute: AppRoutes.splashScreen, getPages: AppPages.routes, diff --git a/lib/feature/profile/controller/profile_controller.dart b/lib/feature/profile/controller/profile_controller.dart index 52f29f0..3fe93d7 100644 --- a/lib/feature/profile/controller/profile_controller.dart +++ b/lib/feature/profile/controller/profile_controller.dart @@ -1,3 +1,6 @@ +import 'dart:ui'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/core/utils/logger.dart'; @@ -38,4 +41,10 @@ class ProfileController extends GetxController { void editProfile() { logC.i("Edit profile pressed"); } + + void changeLanguage(BuildContext context, String languageCode, String countryCode) async { + final locale = Locale(languageCode, countryCode); + await context.setLocale(locale); + Get.updateLocale(locale); + } } diff --git a/lib/feature/profile/view/profile_view.dart b/lib/feature/profile/view/profile_view.dart index 748bc70..ee948db 100644 --- a/lib/feature/profile/view/profile_view.dart +++ b/lib/feature/profile/view/profile_view.dart @@ -37,6 +37,12 @@ class ProfileView extends GetView { controller.editProfile, ), const SizedBox(height: 12), + _buildActionButton( + context.tr("change_language"), + Icons.language, + () => _showLanguageDialog(context), + ), + const SizedBox(height: 12), _buildActionButton( context.tr("logout"), Icons.logout, @@ -126,4 +132,34 @@ class ProfileView extends GetView { ), ); } + + void _showLanguageDialog(BuildContext context) { + showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text(context.tr("select_language")), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.language), + title: const Text("English"), + onTap: () { + controller.changeLanguage(context, 'en', 'US'); + Navigator.of(context).pop(); + }, + ), + ListTile( + leading: const Icon(Icons.language), + title: const Text("Bahasa Indonesia"), + onTap: () { + controller.changeLanguage(context, 'id', "ID"); + Navigator.of(context).pop(); + }, + ), + ], + ), + ), + ); + } } diff --git a/lib/main.dart b/lib/main.dart index 1ecc580..2f584e3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,7 +16,6 @@ void main() { ]); WidgetsFlutterBinding.ensureInitialized(); - await EasyLocalization.ensureInitialized(); runApp( @@ -24,9 +23,11 @@ void main() { supportedLocales: [ Locale('en', 'US'), Locale('id', 'ID'), + Locale('ms', 'MY'), ], path: 'assets/translations', fallbackLocale: Locale('en', 'US'), + useOnlyLangCode: false, child: MyApp(), ), ); From 5f54ca6c8c4c3f9af09a2d3a7c5dafa3778d5eed Mon Sep 17 00:00:00 2001 From: akhdanre Date: Mon, 12 May 2025 22:41:14 +0700 Subject: [PATCH 057/104] feat: adding leave page in the waiting room --- lib/app/app.dart | 2 +- lib/component/global_button.dart | 6 ++-- lib/component/global_text_field.dart | 6 ++-- lib/data/services/socket_service.dart | 12 +++++-- .../controller/join_room_controller.dart | 1 + .../join_room/view/join_room_view.dart | 1 + .../binding/waiting_room_binding.dart | 6 +++- .../controller/waiting_room_controller.dart | 33 +++++++++++++++---- .../waiting_room/view/waiting_room_view.dart | 9 ++++- 9 files changed, 60 insertions(+), 16 deletions(-) diff --git a/lib/app/app.dart b/lib/app/app.dart index 9c22969..e1cf154 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -11,7 +11,7 @@ class MyApp extends StatelessWidget { Widget build(BuildContext context) { return GetMaterialApp( title: 'Quiz App', - locale: Get.locale ?? context.locale, // 🔁 This ensures GetX reacts to locale changes + locale: Get.locale ?? context.locale, fallbackLocale: const Locale('en', 'US'), localizationsDelegates: context.localizationDelegates, supportedLocales: context.supportedLocales, diff --git a/lib/component/global_button.dart b/lib/component/global_button.dart index eb54c81..9effaae 100644 --- a/lib/component/global_button.dart +++ b/lib/component/global_button.dart @@ -6,11 +6,13 @@ class GlobalButton extends StatelessWidget { final VoidCallback? onPressed; final String text; final ButtonType type; + final Color baseColor; const GlobalButton({ super.key, required this.text, required this.onPressed, + this.baseColor = const Color(0xFF0052CC), this.type = ButtonType.primary, }); @@ -24,12 +26,12 @@ class GlobalButton extends StatelessWidget { switch (type) { case ButtonType.primary: - backgroundColor = const Color(0xFF0052CC); + backgroundColor = baseColor; foregroundColor = Colors.white; break; case ButtonType.secondary: backgroundColor = Colors.white; - foregroundColor = const Color(0xFF0052CC); + foregroundColor = baseColor; borderColor = const Color(0xFF0052CC); break; case ButtonType.disabled: diff --git a/lib/component/global_text_field.dart b/lib/component/global_text_field.dart index 73ffe19..2e25c7f 100644 --- a/lib/component/global_text_field.dart +++ b/lib/component/global_text_field.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; class GlobalTextField extends StatelessWidget { final TextEditingController controller; @@ -10,6 +9,7 @@ class GlobalTextField extends StatelessWidget { final bool obscureText; final VoidCallback? onToggleVisibility; final TextInputType textInputType; + final bool forceUpperCase; const GlobalTextField( {super.key, @@ -20,6 +20,7 @@ class GlobalTextField extends StatelessWidget { this.isPassword = false, this.obscureText = false, this.onToggleVisibility, + this.forceUpperCase = false, this.textInputType = TextInputType.text}); @override @@ -28,7 +29,8 @@ class GlobalTextField extends StatelessWidget { controller: controller, keyboardType: textInputType, obscureText: isPassword ? obscureText : false, - maxLines: limitTextLine, // <-- ini tambahan dari limitTextLine + maxLines: limitTextLine, + textCapitalization: forceUpperCase ? TextCapitalization.characters : TextCapitalization.none, decoration: InputDecoration( labelText: labelText, labelStyle: const TextStyle( diff --git a/lib/data/services/socket_service.dart b/lib/data/services/socket_service.dart index b2b8d27..3def272 100644 --- a/lib/data/services/socket_service.dart +++ b/lib/data/services/socket_service.dart @@ -58,15 +58,21 @@ class SocketService { } void joinRoom({required String sessionCode, required String userId}) { - socket.emit('join_room', { + var data = { 'session_code': sessionCode, 'user_id': userId, - }); + }; + print(data); + socket.emit( + 'join_room', + data, + ); } - void leaveRoom({required String sessionId, String username = "anonymous"}) { + void leaveRoom({required String sessionId, required String userId, String username = "anonymous"}) { socket.emit('leave_room', { 'session_id': sessionId, + 'user_id': userId, 'username': username, }); } diff --git a/lib/feature/join_room/controller/join_room_controller.dart b/lib/feature/join_room/controller/join_room_controller.dart index a92ecf4..d5c1b26 100644 --- a/lib/feature/join_room/controller/join_room_controller.dart +++ b/lib/feature/join_room/controller/join_room_controller.dart @@ -27,6 +27,7 @@ class JoinRoomController extends GetxController { return; } _socketService.initSocketConnection(); + _socketService.joinRoom(sessionCode: code, userId: _userController.userData!.id); Get.toNamed(AppRoutes.waitRoomPage, arguments: WaitingRoomDTO(false, SessionResponseModel(sessionId: "", sessionCode: code))); } diff --git a/lib/feature/join_room/view/join_room_view.dart b/lib/feature/join_room/view/join_room_view.dart index 663529a..45103a4 100644 --- a/lib/feature/join_room/view/join_room_view.dart +++ b/lib/feature/join_room/view/join_room_view.dart @@ -54,6 +54,7 @@ class JoinRoomView extends GetView { controller: controller.codeController, hintText: context.tr("room_code_hint"), textInputType: TextInputType.text, + forceUpperCase: true, ), const SizedBox(height: 30), GlobalButton( diff --git a/lib/feature/waiting_room/binding/waiting_room_binding.dart b/lib/feature/waiting_room/binding/waiting_room_binding.dart index ccd1b1c..1fb5fbc 100644 --- a/lib/feature/waiting_room/binding/waiting_room_binding.dart +++ b/lib/feature/waiting_room/binding/waiting_room_binding.dart @@ -1,4 +1,5 @@ import 'package:get/get.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/services/socket_service.dart'; import 'package:quiz_app/feature/waiting_room/controller/waiting_room_controller.dart'; @@ -6,6 +7,9 @@ class WaitingRoomBinding extends Bindings { @override void dependencies() { if (!Get.isRegistered()) Get.put(SocketService()); - Get.lazyPut(() => WaitingRoomController(Get.find())); + Get.lazyPut(() => WaitingRoomController( + Get.find(), + Get.find(), + )); } } diff --git a/lib/feature/waiting_room/controller/waiting_room_controller.dart b/lib/feature/waiting_room/controller/waiting_room_controller.dart index 856231c..72bac11 100644 --- a/lib/feature/waiting_room/controller/waiting_room_controller.dart +++ b/lib/feature/waiting_room/controller/waiting_room_controller.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/core/utils/custom_notification.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/dto/waiting_room_dto.dart'; import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; import 'package:quiz_app/data/models/session/session_response_model.dart'; @@ -10,7 +12,8 @@ import 'package:quiz_app/data/services/socket_service.dart'; class WaitingRoomController extends GetxController { final SocketService _socketService; - WaitingRoomController(this._socketService); + final UserController _userController; + WaitingRoomController(this._socketService, this._userController); final sessionCode = ''.obs; final quizMeta = Rx(null); @@ -20,6 +23,7 @@ class WaitingRoomController extends GetxController { final quizQuestions = >[].obs; final isQuizStarted = false.obs; + SessionResponseModel? roomData; @override void onInit() { super.onInit(); @@ -30,10 +34,10 @@ class WaitingRoomController extends GetxController { void _loadInitialData() { final data = Get.arguments as WaitingRoomDTO; - SessionResponseModel? roomData = data.data; + roomData = data.data; isAdmin.value = data.isAdmin; - sessionCode.value = roomData.sessionCode; + sessionCode.value = roomData!.sessionCode; quizMeta.value = QuizListingModel( quizId: "q123", @@ -49,9 +53,18 @@ class WaitingRoomController extends GetxController { void _registerSocketListeners() { _socketService.roomMessages.listen((data) { - final user = data["data"]; - if (user != null) { - joinedUsers.assign(UserModel(id: user['user_id'], name: user['username'])); + if (data["type"] == "join") { + final user = data["data"]; + if (user != null) { + joinedUsers.assign(UserModel(id: user['user_id'], name: user['username'])); + CustomNotification.success(title: "Participan Joined", message: "${user['username']} has joined to room"); + } + } + + if (data["type"] == "leave") { + final userId = data["data"]; + CustomNotification.warning(title: "Participan Leave", message: "participan leave the room"); + joinedUsers.removeWhere((e) => e.id == userId); } }); @@ -86,4 +99,12 @@ class WaitingRoomController extends GetxController { void startQuiz() { _socketService.startQuiz(sessionCode: sessionCode.value); } + + void leaveRoom() async { + _socketService.leaveRoom(sessionId: roomData!.sessionId, userId: _userController.userData!.id); + Get.offAllNamed(AppRoutes.mainPage); + + await Future.delayed(Duration(seconds: 2)); + _socketService.dispose(); + } } diff --git a/lib/feature/waiting_room/view/waiting_room_view.dart b/lib/feature/waiting_room/view/waiting_room_view.dart index e7f66e8..66caca3 100644 --- a/lib/feature/waiting_room/view/waiting_room_view.dart +++ b/lib/feature/waiting_room/view/waiting_room_view.dart @@ -33,7 +33,13 @@ class WaitingRoomView extends GetView { GlobalButton( text: "Mulai Kuis", onPressed: controller.startQuiz, - ), + ) + else + GlobalButton( + text: "Tinggalkan Ruangan", + onPressed: controller.leaveRoom, + baseColor: const Color.fromARGB(255, 204, 14, 0), + ) ], ); }), @@ -68,6 +74,7 @@ class WaitingRoomView extends GetView { if (quiz == null) return const SizedBox.shrink(); return Container( padding: const EdgeInsets.all(16), + width: double.infinity, decoration: BoxDecoration( color: AppColors.background, border: Border.all(color: AppColors.borderLight), From e060f32593f84601ae53c90d8c1717b2dc0cb252 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Tue, 13 May 2025 02:24:38 +0700 Subject: [PATCH 058/104] feat: adding monitor quiz logic --- lib/app/routes/app_pages.dart | 3 +- lib/data/models/user/user_model.dart | 2 + lib/data/services/socket_service.dart | 72 +++++--- .../binding/monitor_quiz_binding.dart | 10 ++ .../controller/monitor_quiz_controller.dart | 94 +++++++++++ .../monitor_quiz/view/monitor_quiz_view.dart | 158 +++++++++++++++++- .../controller/play_quiz_controller.dart | 76 +++++---- .../view/play_quiz_multiplayer.dart | 46 ++--- .../controller/waiting_room_controller.dart | 12 +- 9 files changed, 375 insertions(+), 98 deletions(-) create mode 100644 lib/feature/monitor_quiz/binding/monitor_quiz_binding.dart create mode 100644 lib/feature/monitor_quiz/controller/monitor_quiz_controller.dart diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index 8344c77..ea622f2 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -14,6 +14,7 @@ import 'package:quiz_app/feature/listing_quiz/binding/listing_quiz_binding.dart' import 'package:quiz_app/feature/listing_quiz/view/listing_quiz_view.dart'; import 'package:quiz_app/feature/login/bindings/login_binding.dart'; import 'package:quiz_app/feature/login/view/login_page.dart'; +import 'package:quiz_app/feature/monitor_quiz/binding/monitor_quiz_binding.dart'; import 'package:quiz_app/feature/monitor_quiz/view/monitor_quiz_view.dart'; import 'package:quiz_app/feature/navigation/bindings/navigation_binding.dart'; import 'package:quiz_app/feature/navigation/views/navbar_view.dart'; @@ -127,7 +128,7 @@ class AppPages { GetPage( name: AppRoutes.monitorQuizMPLPage, page: () => MonitorQuizView(), - // binding: JoinRoomBinding(), + binding: MonitorQuizBinding(), ), GetPage( name: AppRoutes.playQuizMPLPage, diff --git a/lib/data/models/user/user_model.dart b/lib/data/models/user/user_model.dart index eb21881..6ece638 100644 --- a/lib/data/models/user/user_model.dart +++ b/lib/data/models/user/user_model.dart @@ -7,3 +7,5 @@ class UserModel { required this.name, }); } + + diff --git a/lib/data/services/socket_service.dart b/lib/data/services/socket_service.dart index 3def272..7f39b17 100644 --- a/lib/data/services/socket_service.dart +++ b/lib/data/services/socket_service.dart @@ -8,12 +8,23 @@ class SocketService { final _roomMessageController = StreamController>.broadcast(); final _chatMessageController = StreamController>.broadcast(); + final _questionUpdateController = StreamController>.broadcast(); final _quizStartedController = StreamController.broadcast(); + final _answerSubmittedController = StreamController>.broadcast(); + final _scoreUpdateController = StreamController>.broadcast(); + final _quizDoneController = StreamController.broadcast(); + final _roomClosedController = StreamController.broadcast(); final _errorController = StreamController.broadcast(); + // Public streams Stream> get roomMessages => _roomMessageController.stream; + Stream> get questionUpdate => _questionUpdateController.stream; Stream> get chatMessages => _chatMessageController.stream; Stream get quizStarted => _quizStartedController.stream; + Stream> get answerSubmitted => _answerSubmittedController.stream; + Stream> get scoreUpdates => _scoreUpdateController.stream; + Stream get quizDone => _quizDoneController.stream; + Stream get roomClosed => _roomClosedController.stream; Stream get errors => _errorController.stream; void initSocketConnection() { @@ -25,48 +36,67 @@ class SocketService { socket.connect(); socket.onConnect((_) { - logC.i('Connected: ${socket.id}'); + logC.i('✅ Connected: ${socket.id}'); }); socket.onDisconnect((_) { - logC.i('Disconnected'); + logC.i('❌ Disconnected'); }); socket.on('connection_response', (data) { - logC.i('Connection response: $data'); + logC.i('🟢 Connection response: $data'); }); socket.on('room_message', (data) { - logC.i('Room Message: $data'); + logC.i('📥 Room Message: $data'); _roomMessageController.add(Map.from(data)); }); socket.on('receive_message', (data) { - logC.i('Message from ${data['from']}: ${data['message']}'); + logC.i('💬 Chat from ${data['from']}: ${data['message']}'); _chatMessageController.add(Map.from(data)); }); socket.on('quiz_started', (_) { - logC.i('Quiz has started!'); + logC.i('🚀 Quiz Started!'); _quizStartedController.add(null); }); + socket.on('quiz_question', (data) { + logC.i('🚀 question getted!'); + _questionUpdateController.add(Map.from(data)); + }); + + socket.on('answer_submitted', (data) { + logC.i('✅ Answer Submitted: $data'); + _answerSubmittedController.add(Map.from(data)); + }); + + socket.on('score_update', (data) { + logC.i('📊 Score Update: $data'); + _scoreUpdateController.add(Map.from(data)); + }); + + socket.on('quiz_done', (_) { + logC.i('🏁 Quiz Finished!'); + _quizDoneController.add(null); + }); + + socket.on('room_closed', (data) { + logC.i('🔒 Room Closed: $data'); + _roomClosedController.add(data['room'].toString()); + }); socket.on('error', (data) { - logC.i('Socket error: $data'); + logC.e('⚠️ Socket Error: $data'); _errorController.add(data.toString()); }); } void joinRoom({required String sessionCode, required String userId}) { - var data = { + socket.emit('join_room', { 'session_code': sessionCode, 'user_id': userId, - }; - print(data); - socket.emit( - 'join_room', - data, - ); + }); } void leaveRoom({required String sessionId, required String userId, String username = "anonymous"}) { @@ -89,14 +119,12 @@ class SocketService { }); } - /// Emit when admin starts the quiz void startQuiz({required String sessionCode}) { socket.emit('start_quiz', { 'session_code': sessionCode, }); } - /// Emit user's answer during quiz void sendAnswer({ required String sessionId, required String userId, @@ -111,12 +139,8 @@ class SocketService { }); } - /// Emit when user finishes the quiz - void doneQuiz({ - required String sessionId, - required String userId, - }) { - socket.emit('quiz_done', { + void endSession({required String sessionId, required String userId}) { + socket.emit('end_session', { 'session_id': sessionId, 'user_id': userId, }); @@ -127,6 +151,10 @@ class SocketService { _roomMessageController.close(); _chatMessageController.close(); _quizStartedController.close(); + _answerSubmittedController.close(); + _scoreUpdateController.close(); + _quizDoneController.close(); + _roomClosedController.close(); _errorController.close(); } } diff --git a/lib/feature/monitor_quiz/binding/monitor_quiz_binding.dart b/lib/feature/monitor_quiz/binding/monitor_quiz_binding.dart new file mode 100644 index 0000000..6559586 --- /dev/null +++ b/lib/feature/monitor_quiz/binding/monitor_quiz_binding.dart @@ -0,0 +1,10 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/data/services/socket_service.dart'; +import 'package:quiz_app/feature/monitor_quiz/controller/monitor_quiz_controller.dart'; + +class MonitorQuizBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => MonitorQuizController(Get.find())); + } +} diff --git a/lib/feature/monitor_quiz/controller/monitor_quiz_controller.dart b/lib/feature/monitor_quiz/controller/monitor_quiz_controller.dart new file mode 100644 index 0000000..53fba55 --- /dev/null +++ b/lib/feature/monitor_quiz/controller/monitor_quiz_controller.dart @@ -0,0 +1,94 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/core/utils/logger.dart'; +import 'package:quiz_app/data/models/user/user_model.dart'; +import 'package:quiz_app/data/services/socket_service.dart'; + +class MonitorQuizController extends GetxController { + final SocketService _socketService; + + MonitorQuizController(this._socketService); + + String sessionCode = ""; + String sessionId = ""; + + RxString currentQuestion = "".obs; + RxList participan = [].obs; + + @override + void onInit() { + loadData(); + registerListener(); + super.onInit(); + } + + void loadData() { + final args = Get.arguments; + sessionCode = args["session_code"] ?? ""; + sessionId = args["session_id"] ?? ""; + + final List userList = (args["list_participan"] as List).map((e) => e as UserModel).toList(); + participan.assignAll( + userList.map( + (user) => ParticipantAnswerPoint( + id: user.id, + name: user.name, + ), + ), + ); + } + + void registerListener() { + _socketService.questionUpdate.listen((data) { + logC.i(data); + currentQuestion.value = data["question"]; + }); + + _socketService.scoreUpdates.listen((data) { + logC.i("📊 Score Update Received: $data"); + + final Map scoreMap = Map.from(data); + + scoreMap.forEach((userId, scoreData) { + final index = participan.indexWhere((p) => p.id == userId); + + if (index != -1) { + // Participant found, update their scores + final participant = participan[index]; + final correct = scoreData["correct"] ?? 0; + final incorrect = scoreData["incorrect"] ?? 0; + + participant.correct.value = correct; + participant.wrong.value = incorrect; + } else { + // Participant not found, optionally add new participant + participan.add( + ParticipantAnswerPoint( + id: userId, + name: "Unknown", // Or fetch proper name if available + correct: (scoreData["correct"] ?? 0).obs, + wrong: (scoreData["incorrect"] ?? 0).obs, + ), + ); + } + }); + + // Notify observers if needed (optional) + participan.refresh(); + }); + } +} + +class ParticipantAnswerPoint { + final String id; + final String name; + final RxInt correct; + final RxInt wrong; + + ParticipantAnswerPoint({ + required this.id, + required this.name, + RxInt? correct, + RxInt? wrong, + }) : correct = correct ?? 0.obs, + wrong = wrong ?? 0.obs; +} diff --git a/lib/feature/monitor_quiz/view/monitor_quiz_view.dart b/lib/feature/monitor_quiz/view/monitor_quiz_view.dart index 9409ae9..1c09fb7 100644 --- a/lib/feature/monitor_quiz/view/monitor_quiz_view.dart +++ b/lib/feature/monitor_quiz/view/monitor_quiz_view.dart @@ -1,10 +1,164 @@ import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:quiz_app/feature/monitor_quiz/controller/monitor_quiz_controller.dart'; + +class MonitorQuizView extends GetView { + const MonitorQuizView({super.key}); -class MonitorQuizView extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - body: Text("monitor quiz admin"), + appBar: AppBar(title: const Text('Monitor Quiz')), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Obx( + () => _buildCurrentQuestion(questionText: controller.currentQuestion.value), + ), + const SizedBox(height: 24), + const Text( + 'Daftar Peserta:', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + Obx( + () => ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: controller.participan.length, + itemBuilder: (context, index) => _buildUserRow( + name: controller.participan[index].name, + totalBenar: controller.participan[index].correct.value, + totalSalah: controller.participan[index].wrong.value, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildCurrentQuestion({required String questionText}) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.blue.shade50, + border: Border.all(color: Colors.blue), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "pertanyaan sekarang", + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.blue, + ), + ), + SizedBox( + height: 10, + ), + Row( + children: [ + const Icon( + LucideIcons.helpCircle, + color: Colors.blue, + size: 24, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + questionText, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.blue, + ), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildUserRow({ + required String name, + required int totalBenar, + required int totalSalah, + }) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.grey.shade300, + ), + child: const Icon( + Icons.person, + size: 30, + color: Colors.white, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + const Icon( + LucideIcons.checkCircle, + color: Colors.green, + size: 18, + ), + const SizedBox(width: 4), + Text( + 'Benar: $totalBenar', + style: const TextStyle(color: Colors.green), + ), + const SizedBox(width: 16), + const Icon( + LucideIcons.xCircle, + color: Colors.red, + size: 18, + ), + const SizedBox(width: 4), + Text( + 'Salah: $totalSalah', + style: const TextStyle(color: Colors.red), + ), + ], + ), + ], + ), + ), + ], + ), ); } } diff --git a/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart b/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart index 204ab4b..e5587f4 100644 --- a/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart +++ b/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/component/global_button.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/services/socket_service.dart'; @@ -9,59 +10,71 @@ class PlayQuizMultiplayerController extends GetxController { PlayQuizMultiplayerController(this._socketService, this._userController); - final questions = [].obs; + // final questions = [].obs; final Rxn currentQuestion = Rxn(); final currentQuestionIndex = 0.obs; final selectedAnswer = Rxn(); final isDone = false.obs; + final Rx buttonType = ButtonType.disabled.obs; + final fillInAnswerController = TextEditingController(); + bool? selectedTOFAns; late final String sessionCode; late final bool isAdmin; @override void onInit() { + _loadData(); + _registerListener(); super.onInit(); + } + _loadData() { final args = Get.arguments as Map; sessionCode = args["session_code"]; isAdmin = args["is_admin"]; - - _socketService.socket.on("quiz_question", (data) { - final model = MultiplayerQuestionModel.fromJson(Map.from(data)); - currentQuestion.value = model; - questions.add(model); - fillInAnswerController.clear(); // reset tiap soal baru - }); - - _socketService.socket.on("quiz_done", (_) { - isDone.value = true; - }); } - bool isAnswerSelected() { - final type = currentQuestion.value?.type; - if (type == 'fill_in_the_blank') { - return fillInAnswerController.text.trim().isNotEmpty; - } - return selectedAnswer.value != null; + _registerListener() { + fillInAnswerController.addListener(() { + final text = fillInAnswerController.text; + + if (text.isNotEmpty) { + buttonType.value = ButtonType.primary; + } else { + buttonType.value = ButtonType.disabled; + } + }); + + _socketService.questionUpdate.listen((data) { + buttonType.value = ButtonType.disabled; + fillInAnswerController.clear(); + + final model = MultiplayerQuestionModel.fromJson(Map.from(data)); + currentQuestion.value = model; + // questions.add(model); + fillInAnswerController.clear(); // reset tiap soal baru + }); } void selectOptionAnswer(String option) { selectedAnswer.value = option; + buttonType.value = ButtonType.primary; } void selectTrueFalseAnswer(bool value) { selectedAnswer.value = value.toString(); + buttonType.value = ButtonType.primary; } void submitAnswer() { - final question = questions[currentQuestionIndex.value]; + final question = currentQuestion.value!; final type = question.type; String? answer; - if (type == 'fill_in_the_blank') { + if (type == 'fill_the_blank') { answer = fillInAnswerController.text.trim(); } else { answer = selectedAnswer.value; @@ -75,18 +88,6 @@ class PlayQuizMultiplayerController extends GetxController { answer: answer, ); } - - if (currentQuestionIndex.value < questions.length - 1) { - currentQuestionIndex.value++; - selectedAnswer.value = null; - fillInAnswerController.clear(); - } else { - isDone.value = true; - _socketService.doneQuiz( - sessionId: sessionCode, - userId: _userController.userData!.id, - ); - } } @override @@ -100,21 +101,24 @@ class MultiplayerQuestionModel { final int questionIndex; final String question; final String type; // 'option', 'true_false', 'fill_in_the_blank' - final List options; + final int duration; + final List? options; MultiplayerQuestionModel({ required this.questionIndex, required this.question, required this.type, - required this.options, + required this.duration, + this.options, }); factory MultiplayerQuestionModel.fromJson(Map json) { return MultiplayerQuestionModel( - questionIndex: json['question_index'], + questionIndex: json['index'], question: json['question'], type: json['type'], - options: List.from(json['options']), + duration: json['duration'], + options: json['options'] != null ? List.from(json['options']) : null, ); } diff --git a/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart b/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart index c4761fd..c97c9c8 100644 --- a/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart +++ b/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/component/global_button.dart'; import 'package:quiz_app/component/global_text_field.dart'; import 'package:quiz_app/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart'; @@ -12,25 +13,17 @@ class PlayQuizMultiplayerView extends GetView { backgroundColor: Colors.transparent, elevation: 0, centerTitle: true, - title: Obx(() { - if (controller.questions.isEmpty) { - return const Text( - "Loading...", - style: TextStyle(color: Colors.black, fontWeight: FontWeight.bold), - ); - } - return Text( - "Soal ${controller.currentQuestionIndex.value + 1}/${controller.questions.length}", - style: const TextStyle(color: Colors.black, fontWeight: FontWeight.bold), - ); - }), + title: Text( + "Soal ${(controller.currentQuestion.value?.questionIndex ?? 0) + 1}/10", + style: const TextStyle(color: Colors.black, fontWeight: FontWeight.bold), + ), ), body: Obx(() { if (controller.isDone.value) { return _buildDoneView(); } - if (controller.questions.isEmpty) { + if (controller.currentQuestion.value == null) { return const Center(child: CircularProgressIndicator()); } @@ -52,25 +45,16 @@ class PlayQuizMultiplayerView extends GetView { ), const SizedBox(height: 20), if (question.type == 'option') _buildOptionQuestion(), - if (question.type == 'fill_in_the_blank') _buildFillInBlankQuestion(), + if (question.type == 'fill_the_blank') _buildFillInBlankQuestion(), if (question.type == 'true_false') _buildTrueFalseQuestion(), const Spacer(), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: controller.isAnswerSelected() ? controller.submitAnswer : null, - style: ElevatedButton.styleFrom( - backgroundColor: controller.isAnswerSelected() ? const Color(0xFF2563EB) : Colors.grey, - foregroundColor: Colors.white, - minimumSize: const Size(double.infinity, 50), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - child: const Text( - "Kirim Jawaban", - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), - ), + Obx( + () => GlobalButton( + text: "Kirim jawaban", + onPressed: controller.submitAnswer, + type: controller.buttonType.value, ), - ), + ) ], ), ); @@ -79,7 +63,7 @@ class PlayQuizMultiplayerView extends GetView { Widget _buildOptionQuestion() { final options = controller.currentQuestion.value!.options; return Column( - children: List.generate(options.length, (index) { + children: List.generate(options!.length, (index) { final option = options[index]; final isSelected = controller.selectedAnswer.value == option; @@ -121,7 +105,7 @@ class PlayQuizMultiplayerView extends GetView { } Widget _buildTrueFalseButton(String label, bool value) { - final isSelected = controller.selectedAnswer.value == value.toString(); + final isSelected = controller.selectedTOFAns = value; return Container( margin: const EdgeInsets.only(bottom: 12), diff --git a/lib/feature/waiting_room/controller/waiting_room_controller.dart b/lib/feature/waiting_room/controller/waiting_room_controller.dart index 72bac11..f483d77 100644 --- a/lib/feature/waiting_room/controller/waiting_room_controller.dart +++ b/lib/feature/waiting_room/controller/waiting_room_controller.dart @@ -71,12 +71,7 @@ class WaitingRoomController extends GetxController { _socketService.quizStarted.listen((_) { isQuizStarted.value = true; Get.snackbar("Info", "Kuis telah dimulai"); - if (isAdmin.value) { - Get.offAllNamed(AppRoutes.monitorQuizMPLPage, arguments: { - "session_code": sessionCode.value, - "is_admin": isAdmin.value, - }); - } else { + if (!isAdmin.value) { Get.offAllNamed(AppRoutes.playQuizMPLPage, arguments: { "session_code": sessionCode.value, "is_admin": isAdmin.value, @@ -98,6 +93,11 @@ class WaitingRoomController extends GetxController { void startQuiz() { _socketService.startQuiz(sessionCode: sessionCode.value); + Get.offAllNamed(AppRoutes.monitorQuizMPLPage, arguments: { + "session_code": sessionCode.value, + "is_admin": isAdmin.value, + "list_participan": joinedUsers.toList(), + }); } void leaveRoom() async { From 053c7db78c417b81ef1ef0c220267b232c4bfb82 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Tue, 13 May 2025 02:55:58 +0700 Subject: [PATCH 059/104] feat: adjust play quiz multiplayer --- .../controller/play_quiz_controller.dart | 11 ++++++++--- .../view/play_quiz_multiplayer.dart | 17 +++++++++++++++-- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart b/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart index e5587f4..f0881b7 100644 --- a/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart +++ b/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart @@ -19,7 +19,7 @@ class PlayQuizMultiplayerController extends GetxController { final Rx buttonType = ButtonType.disabled.obs; final fillInAnswerController = TextEditingController(); - bool? selectedTOFAns; + RxBool isASentAns = false.obs; late final String sessionCode; late final bool isAdmin; @@ -51,11 +51,15 @@ class PlayQuizMultiplayerController extends GetxController { _socketService.questionUpdate.listen((data) { buttonType.value = ButtonType.disabled; fillInAnswerController.clear(); + isASentAns.value = false; final model = MultiplayerQuestionModel.fromJson(Map.from(data)); currentQuestion.value = model; - // questions.add(model); - fillInAnswerController.clear(); // reset tiap soal baru + fillInAnswerController.clear(); + }); + + _socketService.quizDone.listen((_) { + isDone.value = true; }); } @@ -87,6 +91,7 @@ class PlayQuizMultiplayerController extends GetxController { questionIndex: question.questionIndex, answer: answer, ); + isASentAns.value = true; } } diff --git a/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart b/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart index c97c9c8..42faa29 100644 --- a/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart +++ b/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart @@ -14,7 +14,7 @@ class PlayQuizMultiplayerView extends GetView { elevation: 0, centerTitle: true, title: Text( - "Soal ${(controller.currentQuestion.value?.questionIndex ?? 0) + 1}/10", + "Soal ${(controller.currentQuestion.value?.questionIndex ?? 0)}/10", style: const TextStyle(color: Colors.black, fontWeight: FontWeight.bold), ), ), @@ -27,6 +27,9 @@ class PlayQuizMultiplayerView extends GetView { return const Center(child: CircularProgressIndicator()); } + if (controller.isASentAns.value) { + return const Center(child: Text("you already answer, please wait until the duration is done")); + } return _buildQuestionView(); }), ); @@ -105,7 +108,7 @@ class PlayQuizMultiplayerView extends GetView { } Widget _buildTrueFalseButton(String label, bool value) { - final isSelected = controller.selectedTOFAns = value; + final isSelected = controller.selectedAnswer.value == value.toString(); return Container( margin: const EdgeInsets.only(bottom: 12), @@ -152,4 +155,14 @@ class PlayQuizMultiplayerView extends GetView { ), ); } + + // Widget _buildProgressBar() { + // final question = controller.currentQuestion; + // return LinearProgressIndicator( + // value: controller.timeLeft.value / question.duration, + // minHeight: 8, + // backgroundColor: Colors.grey[300], + // valueColor: const AlwaysStoppedAnimation(Color(0xFF2563EB)), + // ); + // } } From 381be0db1e725e2d6f13c13fd1ba8a928a0fbf66 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Thu, 15 May 2025 21:47:09 +0700 Subject: [PATCH 060/104] feat: creating test for login and register controller --- .../login/controllers/login_controller.dart | 6 +- pubspec.lock | 128 +++++++++++++++++ pubspec.yaml | 1 + test/controller/login_controller_test.dart | 135 ++++++++++++++++++ test/controller/register_controller_test.dart | 115 +++++++++++++++ 5 files changed, 382 insertions(+), 3 deletions(-) create mode 100644 test/controller/login_controller_test.dart create mode 100644 test/controller/register_controller_test.dart diff --git a/lib/feature/login/controllers/login_controller.dart b/lib/feature/login/controllers/login_controller.dart index 2dd8f6b..7b4da7f 100644 --- a/lib/feature/login/controllers/login_controller.dart +++ b/lib/feature/login/controllers/login_controller.dart @@ -34,11 +34,11 @@ class LoginController extends GetxController { @override void onInit() { super.onInit(); - emailController.addListener(_validateFields); - passwordController.addListener(_validateFields); + emailController.addListener(validateFields); + passwordController.addListener(validateFields); } - void _validateFields() { + void validateFields() { final isEmailNotEmpty = emailController.text.trim().isNotEmpty; final isPasswordNotEmpty = passwordController.text.trim().isNotEmpty; isButtonEnabled.value = (isEmailNotEmpty && isPasswordNotEmpty) ? ButtonType.primary : ButtonType.disabled; diff --git a/pubspec.lock b/pubspec.lock index 2e87f83..f409f2b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,22 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: e55636ed79578b9abca5fecf9437947798f5ef7456308b5cb85720b793eac92f + url: "https://pub.dev" + source: hosted + version: "82.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "904ae5bb474d32c38fb9482e2d925d5454cda04ddd0e55d2e6826bc72f6ba8c0" + url: "https://pub.dev" + source: hosted + version: "7.4.5" args: dependency: transitive description: @@ -25,6 +41,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + build: + dependency: transitive + description: + name: build + sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: ea90e81dc4a25a043d9bee692d20ed6d1c4a1662a28c03a96417446c093ed6b4 + url: "https://pub.dev" + source: hosted + version: "8.9.5" characters: dependency: transitive description: @@ -41,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" + url: "https://pub.dev" + source: hosted + version: "4.10.1" collection: dependency: transitive description: @@ -49,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.0" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" crypto: dependency: transitive description: @@ -65,6 +121,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "27eb0ae77836989a3bc541ce55595e8ceee0992807f14511552a898ddd0d88ac" + url: "https://pub.dev" + source: hosted + version: "3.0.1" dio: dependency: "direct main" description: @@ -121,6 +185,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -165,6 +237,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.6.6" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" google_fonts: dependency: "direct main" description: @@ -325,6 +405,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.15.0" + mockito: + dependency: "direct dev" + description: + name: mockito + sha256: f99d8d072e249f719a5531735d146d8cf04c580d93920b04de75bef6dfb2daf6 + url: "https://pub.dev" + source: hosted + version: "5.4.5" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" path: dependency: transitive description: @@ -397,6 +493,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" shared_preferences: dependency: "direct main" description: @@ -474,6 +578,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.1" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + url: "https://pub.dev" + source: hosted + version: "2.0.0" source_span: dependency: transitive description: @@ -546,6 +658,14 @@ packages: url: "https://pub.dev" source: hosted version: "14.3.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" + url: "https://pub.dev" + source: hosted + version: "1.1.1" web: dependency: transitive description: @@ -562,6 +682,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: dart: ">=3.6.0 <4.0.0" flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml index 823951f..c5c13c3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,6 +48,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + mockito: ^5.4.4 # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is diff --git a/test/controller/login_controller_test.dart b/test/controller/login_controller_test.dart new file mode 100644 index 0000000..55874cf --- /dev/null +++ b/test/controller/login_controller_test.dart @@ -0,0 +1,135 @@ +// import 'package:flutter/material.dart'; +// import 'package:flutter_test/flutter_test.dart'; +// import 'package:get/get.dart'; +// import 'package:mockito/mockito.dart'; +// import 'package:quiz_app/app/routes/app_pages.dart'; +// import 'package:quiz_app/component/global_button.dart'; +// import 'package:quiz_app/data/controllers/user_controller.dart'; +// import 'package:quiz_app/data/entity/user/user_entity.dart'; +// import 'package:quiz_app/data/models/login/login_request_model.dart'; +// import 'package:quiz_app/data/models/login/login_response_model.dart'; +// import 'package:quiz_app/data/services/auth_service.dart'; +// import 'package:quiz_app/data/services/google_auth_service.dart'; +// import 'package:quiz_app/data/services/user_storage_service.dart'; +// import 'package:quiz_app/feature/login/controllers/login_controller.dart'; + +// class MockAuthService extends Mock implements AuthService {} + +// class MockUserStorageService extends Mock implements UserStorageService {} + +// class MockUserController extends Mock implements UserController {} + +// class MockGoogleAuthService extends Mock implements GoogleAuthService {} + +// void main() { +// late LoginController loginController; +// late MockAuthService mockAuthService; +// late MockUserStorageService mockUserStorageService; +// late MockUserController mockUserController; +// late MockGoogleAuthService mockGoogleAuthService; + +// setUp(() { +// mockAuthService = MockAuthService(); +// mockUserStorageService = MockUserStorageService(); +// mockUserController = MockUserController(); +// mockGoogleAuthService = MockGoogleAuthService(); + +// loginController = LoginController( +// mockAuthService, +// mockUserStorageService, +// mockUserController, +// mockGoogleAuthService, +// ); +// }); + +// test('Initial state should have button disabled and password hidden', () { +// expect(loginController.isButtonEnabled.value, ButtonType.disabled); +// expect(loginController.isPasswordHidden.value, true); +// }); + +// test('Button should enable when both fields are not empty', () { +// loginController.emailController.text = 'test@example.com'; +// loginController.passwordController.text = 'password123'; + +// loginController.validateFields(); + +// expect(loginController.isButtonEnabled.value, ButtonType.primary); +// }); + +// test('Button should disable if email or password is empty', () { +// loginController.emailController.text = ''; +// loginController.passwordController.text = 'password123'; + +// loginController.validateFields(); + +// expect(loginController.isButtonEnabled.value, ButtonType.disabled); +// }); + +// test('Toggle password visibility works', () { +// final initial = loginController.isPasswordHidden.value; +// loginController.togglePasswordVisibility(); +// expect(loginController.isPasswordHidden.value, !initial); +// }); + +// test('Successful email login navigates to main page', () async { +// final response = LoginResponseModel(id: '1', name: 'John', email: 'john@example.com'); + +// when(mockAuthService.loginWithEmail(any)).thenAnswer((_) async => response); +// when(mockUserStorageService.saveUser(any)).thenAnswer((_) async => {}); +// when(mockUserStorageService.isLogged = true).thenReturn(true); + +// loginController.emailController.text = 'john@example.com'; +// loginController.passwordController.text = 'password123'; + +// await loginController.loginWithEmail(); + +// verify(mockAuthService.loginWithEmail(any)).called(1); +// verify(mockUserStorageService.saveUser(any)).called(1); +// verify(mockUserController.setUserFromEntity(any)).called(1); +// }); + +// test('Login with empty fields should show error', () async { +// loginController.emailController.text = ''; +// loginController.passwordController.text = ''; + +// await loginController.loginWithEmail(); + +// expect(loginController.isLoading.value, false); +// }); + +// test('Google login canceled should show error', () async { +// when(mockGoogleAuthService.signIn()).thenAnswer((_) async => null); + +// await loginController.loginWithGoogle(); + +// verifyNever(mockAuthService.loginWithGoogle(any)); +// }); + +// test('Successful Google login navigates to main page', () async { +// final fakeGoogleUser = FakeGoogleUser(); +// final response = LoginResponseModel(id: '1', name: 'John', email: 'john@example.com'); + +// when(mockGoogleAuthService.signIn()).thenAnswer((_) async => fakeGoogleUser); +// when(fakeGoogleUser.authentication).thenAnswer((_) async => FakeGoogleAuth()); +// when(mockAuthService.loginWithGoogle(any)).thenAnswer((_) async => response); +// when(mockUserStorageService.saveUser(any)).thenAnswer((_) async => {}); +// when(mockUserStorageService.isLogged = true).thenReturn(true); + +// await loginController.loginWithGoogle(); + +// verify(mockAuthService.loginWithGoogle(any)).called(1); +// verify(mockUserStorageService.saveUser(any)).called(1); +// verify(mockUserController.setUserFromEntity(any)).called(1); +// }); +// } + +// /// Fakes for Google Sign-In +// class FakeGoogleUser extends Mock { +// @override +// Future get authentication async => FakeGoogleAuth(); +// } + +// class FakeGoogleAuth extends Mock { +// @override +// Future get idToken async => 'fake_id_token'; +// } diff --git a/test/controller/register_controller_test.dart b/test/controller/register_controller_test.dart new file mode 100644 index 0000000..6ef2ef3 --- /dev/null +++ b/test/controller/register_controller_test.dart @@ -0,0 +1,115 @@ +// import 'package:flutter_test/flutter_test.dart'; +// import 'package:mockito/mockito.dart'; +// import 'package:quiz_app/data/models/register/register_request.dart'; +// import 'package:quiz_app/data/services/auth_service.dart'; +// import 'package:quiz_app/feature/register/controller/register_controller.dart'; + +// // Mock class for AuthService +// class MockAuthService extends Mock implements AuthService {} + +// void main() { +// late RegisterController registerController; +// late MockAuthService mockAuthService; + +// setUp(() { +// mockAuthService = MockAuthService(); +// registerController = RegisterController(mockAuthService); +// }); + +// test('Initial state should have password and confirm password hidden', () { +// expect(registerController.isPasswordHidden.value, true); +// expect(registerController.isConfirmPasswordHidden.value, true); +// }); + +// test('Toggle password visibility works correctly', () { +// final initial = registerController.isPasswordHidden.value; +// registerController.togglePasswordVisibility(); +// expect(registerController.isPasswordHidden.value, !initial); +// }); + +// test('Toggle confirm password visibility works correctly', () { +// final initial = registerController.isConfirmPasswordHidden.value; +// registerController.toggleConfirmPasswordVisibility(); +// expect(registerController.isConfirmPasswordHidden.value, !initial); +// }); + +// test('Invalid email format should show error', () async { +// registerController.emailController.text = 'invalid-email'; +// registerController.nameController.text = 'John Doe'; +// registerController.bDateController.text = '12-12-2000'; +// registerController.passwordController.text = 'password123'; +// registerController.confirmPasswordController.text = 'password123'; + +// await registerController.onRegister(); + + + +// // Verify no call to register method due to invalid email +// verifyNever(() => mockAuthService.register(any())); +// }); + +// // test('Invalid date format should show error', () async { +// // registerController.emailController.text = 'john@example.com'; +// // registerController.nameController.text = 'John Doe'; +// // registerController.bDateController.text = '12/12/2000'; // Invalid format +// // registerController.passwordController.text = 'password123'; +// // registerController.confirmPasswordController.text = 'password123'; + +// // await registerController.onRegister(); + +// // verifyNever(mockAuthService.register(any)); +// // }); + +// // test('Passwords do not match should show error', () async { +// // registerController.emailController.text = 'john@example.com'; +// // registerController.nameController.text = 'John Doe'; +// // registerController.bDateController.text = '12-12-2000'; +// // registerController.passwordController.text = 'password123'; +// // registerController.confirmPasswordController.text = 'password456'; + +// // await registerController.onRegister(); + +// // verifyNever(mockAuthService.register(any)); +// // }); + +// // test('Phone number invalid length should show error', () async { +// // registerController.emailController.text = 'john@example.com'; +// // registerController.nameController.text = 'John Doe'; +// // registerController.bDateController.text = '12-12-2000'; +// // registerController.passwordController.text = 'password123'; +// // registerController.confirmPasswordController.text = 'password123'; +// // registerController.phoneController.text = '123'; // Too short + +// // await registerController.onRegister(); + +// // verifyNever(mockAuthService.register(any)); +// // }); + +// // test('Successful registration calls AuthService and navigates back', () async { +// // registerController.emailController.text = 'john@example.com'; +// // registerController.nameController.text = 'John Doe'; +// // registerController.bDateController.text = '12-12-2000'; +// // registerController.passwordController.text = 'password123'; +// // registerController.confirmPasswordController.text = 'password123'; +// // registerController.phoneController.text = '081234567890'; + +// // when(mockAuthService.register(any)).thenAnswer((_) async => {}); + +// // await registerController.onRegister(); + +// // verify(mockAuthService.register(any)).called(1); +// // }); + +// // test('_isValidDateFormat should return correct results', () { +// // expect(registerController._isValidDateFormat('12-12-2000'), true); +// // expect(registerController._isValidDateFormat('31-02-2000'), true); // Format correct, logic not checked +// // expect(registerController._isValidDateFormat('12/12/2000'), false); +// // expect(registerController._isValidDateFormat('2000-12-12'), false); +// // }); + +// // test('_isValidEmail should return correct results', () { +// // expect(registerController._isValidEmail('test@example.com'), true); +// // expect(registerController._isValidEmail('test.example.com'), false); +// // expect(registerController._isValidEmail('test@com'), false); +// // }); +// } From 1cce1aba2c3a6e07b4f50b02ca8bb7cfd943712f Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sat, 17 May 2025 14:54:03 +0700 Subject: [PATCH 061/104] feat: quiz listings on the room maker --- lib/data/services/auth_service.dart | 2 + .../binding/room_maker_binding.dart | 3 ++ .../controller/room_maker_controller.dart | 53 +++++++++++-------- 3 files changed, 35 insertions(+), 23 deletions(-) diff --git a/lib/data/services/auth_service.dart b/lib/data/services/auth_service.dart index 260c8f0..c2bfad6 100644 --- a/lib/data/services/auth_service.dart +++ b/lib/data/services/auth_service.dart @@ -1,3 +1,4 @@ + import 'package:dio/dio.dart'; import 'package:get/get.dart'; import 'package:quiz_app/core/endpoint/api_endpoint.dart'; @@ -33,6 +34,7 @@ class AuthService extends GetxService { final response = await _dio.post(APIEndpoint.login, data: data); if (response.statusCode == 200) { + print(response.data); final baseResponse = BaseResponseModel.fromJson( response.data, (json) => LoginResponseModel.fromJson(json), diff --git a/lib/feature/room_maker/binding/room_maker_binding.dart b/lib/feature/room_maker/binding/room_maker_binding.dart index 65463ed..24d6211 100644 --- a/lib/feature/room_maker/binding/room_maker_binding.dart +++ b/lib/feature/room_maker/binding/room_maker_binding.dart @@ -1,5 +1,6 @@ import 'package:get/get.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/services/quiz_service.dart'; import 'package:quiz_app/data/services/session_service.dart'; import 'package:quiz_app/data/services/socket_service.dart'; import 'package:quiz_app/feature/room_maker/controller/room_maker_controller.dart'; @@ -8,11 +9,13 @@ class RoomMakerBinding extends Bindings { @override void dependencies() { Get.lazyPut(() => SessionService()); + Get.lazyPut(() => QuizService()); Get.put(SocketService()); Get.lazyPut(() => RoomMakerController( Get.find(), Get.find(), Get.find(), + Get.find(), )); } } diff --git a/lib/feature/room_maker/controller/room_maker_controller.dart b/lib/feature/room_maker/controller/room_maker_controller.dart index f301fb3..4e0e0cf 100644 --- a/lib/feature/room_maker/controller/room_maker_controller.dart +++ b/lib/feature/room_maker/controller/room_maker_controller.dart @@ -3,8 +3,10 @@ import 'package:get/get.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/dto/waiting_room_dto.dart'; +import 'package:quiz_app/data/models/base/base_model.dart'; import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; import 'package:quiz_app/data/models/session/session_request_model.dart'; +import 'package:quiz_app/data/services/quiz_service.dart'; import 'package:quiz_app/data/services/session_service.dart'; import 'package:quiz_app/data/services/socket_service.dart'; @@ -12,11 +14,13 @@ class RoomMakerController extends GetxController { final SessionService _sessionService; final UserController _userController; final SocketService _socketService; + final QuizService _quizService; RoomMakerController( this._sessionService, this._userController, this._socketService, + this._quizService, ); // final roomName = ''.obs; @@ -27,28 +31,20 @@ class RoomMakerController extends GetxController { final TextEditingController nameTC = TextEditingController(); final TextEditingController maxPlayerTC = TextEditingController(); - final availableQuizzes = [ - QuizListingModel( - quizId: '1', - authorId: 'u1', - authorName: 'Admin', - title: 'Sejarah Indonesia', - description: 'Kuis tentang kerajaan dan sejarah nusantara.', - date: '2025-05-01', - totalQuiz: 10, - duration: 600, - ), - QuizListingModel( - quizId: '2', - authorId: 'u2', - authorName: 'Guru IPA', - title: 'Ilmu Pengetahuan Alam', - description: 'Kuis IPA untuk kelas 8.', - date: '2025-04-28', - totalQuiz: 15, - duration: 900, - ), - ].obs; + final availableQuizzes = [].obs; + + @override + void onInit() { + loadQuiz(); + super.onInit(); + } + + loadQuiz() async { + BaseResponseModel>? response = await _quizService.userQuiz(_userController.userData!.id, 1); + if (response != null) { + availableQuizzes.assignAll(response.data!); + } + } void onCreateRoom() async { if (nameTC.text.trim().isEmpty || selectedQuiz.value == null) { @@ -74,8 +70,19 @@ class RoomMakerController extends GetxController { } } - void onQuizSourceChange(bool base) { + void onQuizSourceChange(bool base) async { isOnwQuiz.value = base; + if (base) { + BaseResponseModel>? response = await _quizService.userQuiz(_userController.userData!.id, 1); + if (response != null) { + availableQuizzes.assignAll(response.data!); + } + return; + } + BaseResponseModel>? response = await _quizService.recomendationQuiz(page: 1, amount: 4); + if (response != null) { + availableQuizzes.assignAll(response.data!); + } } void onQuizChoosen(String quizId) { From 81d900878f3579af8f551ff992d59cada3dabab8 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sat, 17 May 2025 16:20:18 +0700 Subject: [PATCH 062/104] fix: join system --- lib/data/dto/waiting_room_dto.dart | 11 +++- lib/data/models/quiz/quiz_info_model.dart | 31 ++++++++++ .../models/session/session_info_model.dart | 61 +++++++++++++++++++ lib/data/models/user/user_model.dart | 19 ++++-- .../controller/join_room_controller.dart | 30 ++++++++- .../controller/monitor_quiz_controller.dart | 2 +- .../controller/room_maker_controller.dart | 24 +++++++- .../controller/waiting_room_controller.dart | 58 +++++++++++------- .../waiting_room/view/waiting_room_view.dart | 13 ++-- 9 files changed, 213 insertions(+), 36 deletions(-) create mode 100644 lib/data/models/quiz/quiz_info_model.dart create mode 100644 lib/data/models/session/session_info_model.dart diff --git a/lib/data/dto/waiting_room_dto.dart b/lib/data/dto/waiting_room_dto.dart index f72541f..bad643e 100644 --- a/lib/data/dto/waiting_room_dto.dart +++ b/lib/data/dto/waiting_room_dto.dart @@ -1,8 +1,17 @@ +import 'package:quiz_app/data/models/quiz/quiz_info_model.dart'; +import 'package:quiz_app/data/models/session/session_info_model.dart'; import 'package:quiz_app/data/models/session/session_response_model.dart'; class WaitingRoomDTO { final bool isAdmin; final SessionResponseModel data; + final SessionInfo sessionInfo; + final QuizInfo quizInfo; - WaitingRoomDTO(this.isAdmin, this.data); + WaitingRoomDTO({ + required this.isAdmin, + required this.data, + required this.sessionInfo, + required this.quizInfo, + }); } diff --git a/lib/data/models/quiz/quiz_info_model.dart b/lib/data/models/quiz/quiz_info_model.dart new file mode 100644 index 0000000..5a54afd --- /dev/null +++ b/lib/data/models/quiz/quiz_info_model.dart @@ -0,0 +1,31 @@ +class QuizInfo { + final String title; + final String description; + final int totalQuiz; + final int limitDuration; + + QuizInfo({ + required this.title, + required this.description, + required this.totalQuiz, + required this.limitDuration, + }); + + factory QuizInfo.fromJson(Map json) { + return QuizInfo( + title: json['title'], + description: json['description'], + totalQuiz: json['total_quiz'], + limitDuration: json['limit_duration'], + ); + } + + Map toJson() { + return { + 'title': title, + 'description': description, + 'question_count': totalQuiz, + 'limit_duration': limitDuration, + }; + } +} diff --git a/lib/data/models/session/session_info_model.dart b/lib/data/models/session/session_info_model.dart new file mode 100644 index 0000000..cca5d1d --- /dev/null +++ b/lib/data/models/session/session_info_model.dart @@ -0,0 +1,61 @@ +import 'package:quiz_app/data/models/user/user_model.dart'; + +class SessionInfo { + final String id; + final String sessionCode; + final String quizId; + final String hostId; + final DateTime createdAt; + final DateTime? startedAt; + final DateTime? endedAt; + final bool isActive; + final int participantLimit; + final List participants; + final int currentQuestionIndex; + + SessionInfo({ + required this.id, + required this.sessionCode, + required this.quizId, + required this.hostId, + required this.createdAt, + this.startedAt, + this.endedAt, + required this.isActive, + required this.participantLimit, + required this.participants, + required this.currentQuestionIndex, + }); + + factory SessionInfo.fromJson(Map json) { + return SessionInfo( + id: json['id'], + sessionCode: json['session_code'], + quizId: json['quiz_id'], + hostId: json['host_id'], + createdAt: DateTime.parse(json['created_at']), + startedAt: json['started_at'] != null ? DateTime.parse(json['started_at']) : null, + endedAt: json['ended_at'] != null ? DateTime.parse(json['ended_at']) : null, + isActive: json['is_active'], + participantLimit: json['participan_limit'], + participants: (json['participants'] as List?)?.map((e) => UserModel.fromJson(e as Map)).toList() ?? [], + currentQuestionIndex: json['current_question_index'], + ); + } + + Map toJson() { + return { + 'id': id, + 'session_code': sessionCode, + 'quiz_id': quizId, + 'host_id': hostId, + 'created_at': createdAt.toIso8601String(), + 'started_at': startedAt?.toIso8601String(), + 'ended_at': endedAt?.toIso8601String(), + 'is_active': isActive, + 'participant_limit': participantLimit, + 'participants': participants, + 'current_question_index': currentQuestionIndex, + }; + } +} diff --git a/lib/data/models/user/user_model.dart b/lib/data/models/user/user_model.dart index 6ece638..72769d4 100644 --- a/lib/data/models/user/user_model.dart +++ b/lib/data/models/user/user_model.dart @@ -1,11 +1,22 @@ class UserModel { final String id; - final String name; + final String username; + final String userPic; + final DateTime joinedAt; UserModel({ required this.id, - required this.name, + required this.username, + required this.userPic, + required this.joinedAt, }); + + factory UserModel.fromJson(Map json) { + return UserModel( + id: json['id'], + username: json['username'], + userPic: json['user_pic'] ?? "", + joinedAt: DateTime.parse(json['joined_at']), + ); + } } - - diff --git a/lib/feature/join_room/controller/join_room_controller.dart b/lib/feature/join_room/controller/join_room_controller.dart index d5c1b26..5577158 100644 --- a/lib/feature/join_room/controller/join_room_controller.dart +++ b/lib/feature/join_room/controller/join_room_controller.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/core/utils/logger.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/dto/waiting_room_dto.dart'; +import 'package:quiz_app/data/models/quiz/quiz_info_model.dart'; +import 'package:quiz_app/data/models/session/session_info_model.dart'; import 'package:quiz_app/data/models/session/session_response_model.dart'; import 'package:quiz_app/data/services/socket_service.dart'; @@ -27,9 +30,32 @@ class JoinRoomController extends GetxController { return; } _socketService.initSocketConnection(); - + _socketService.joinRoom(sessionCode: code, userId: _userController.userData!.id); - Get.toNamed(AppRoutes.waitRoomPage, arguments: WaitingRoomDTO(false, SessionResponseModel(sessionId: "", sessionCode: code))); + _socketService.errors.listen((error) { + logC.i(error); + }); + + _socketService.roomMessages.listen((data) { + if (data["type"] == "join") { + final Map dataPayload = data["data"]; + final Map sessionInfoJson = dataPayload["session_info"]; + final Map quizInfoJson = dataPayload["quiz_info"]; + + Get.toNamed( + AppRoutes.waitRoomPage, + arguments: WaitingRoomDTO( + isAdmin: false, + data: SessionResponseModel( + sessionId: sessionInfoJson["id"], + sessionCode: sessionInfoJson["session_code"], + ), + sessionInfo: SessionInfo.fromJson(sessionInfoJson), + quizInfo: QuizInfo.fromJson(quizInfoJson), + ), + ); + } + }); } @override diff --git a/lib/feature/monitor_quiz/controller/monitor_quiz_controller.dart b/lib/feature/monitor_quiz/controller/monitor_quiz_controller.dart index 53fba55..f4946d8 100644 --- a/lib/feature/monitor_quiz/controller/monitor_quiz_controller.dart +++ b/lib/feature/monitor_quiz/controller/monitor_quiz_controller.dart @@ -31,7 +31,7 @@ class MonitorQuizController extends GetxController { userList.map( (user) => ParticipantAnswerPoint( id: user.id, - name: user.name, + name: user.username, ), ), ); diff --git a/lib/feature/room_maker/controller/room_maker_controller.dart b/lib/feature/room_maker/controller/room_maker_controller.dart index 4e0e0cf..904027f 100644 --- a/lib/feature/room_maker/controller/room_maker_controller.dart +++ b/lib/feature/room_maker/controller/room_maker_controller.dart @@ -4,8 +4,11 @@ import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/dto/waiting_room_dto.dart'; import 'package:quiz_app/data/models/base/base_model.dart'; +import 'package:quiz_app/data/models/quiz/quiz_info_model.dart'; import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; +import 'package:quiz_app/data/models/session/session_info_model.dart'; import 'package:quiz_app/data/models/session/session_request_model.dart'; +import 'package:quiz_app/data/models/session/session_response_model.dart'; import 'package:quiz_app/data/services/quiz_service.dart'; import 'package:quiz_app/data/services/session_service.dart'; import 'package:quiz_app/data/services/socket_service.dart'; @@ -66,7 +69,26 @@ class RoomMakerController extends GetxController { _socketService.initSocketConnection(); _socketService.joinRoom(sessionCode: response.data!.sessionCode, userId: _userController.userData!.id); - Get.toNamed(AppRoutes.waitRoomPage, arguments: WaitingRoomDTO(true, response.data!)); + _socketService.roomMessages.listen((data) { + if (data["type"] == "join") { + final Map dataPayload = data["data"]; + final Map sessionInfoJson = dataPayload["session_info"]; + final Map quizInfoJson = dataPayload["quiz_info"]; + + Get.toNamed( + AppRoutes.waitRoomPage, + arguments: WaitingRoomDTO( + isAdmin: true, + data: SessionResponseModel( + sessionId: sessionInfoJson["id"], + sessionCode: sessionInfoJson["session_code"], + ), + sessionInfo: SessionInfo.fromJson(sessionInfoJson), + quizInfo: QuizInfo.fromJson(quizInfoJson), + ), + ); + } + }); } } diff --git a/lib/feature/waiting_room/controller/waiting_room_controller.dart b/lib/feature/waiting_room/controller/waiting_room_controller.dart index f483d77..6295739 100644 --- a/lib/feature/waiting_room/controller/waiting_room_controller.dart +++ b/lib/feature/waiting_room/controller/waiting_room_controller.dart @@ -5,7 +5,7 @@ import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/core/utils/custom_notification.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/dto/waiting_room_dto.dart'; -import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; +import 'package:quiz_app/data/models/quiz/quiz_info_model.dart'; import 'package:quiz_app/data/models/session/session_response_model.dart'; import 'package:quiz_app/data/models/user/user_model.dart'; import 'package:quiz_app/data/services/socket_service.dart'; @@ -16,7 +16,7 @@ class WaitingRoomController extends GetxController { WaitingRoomController(this._socketService, this._userController); final sessionCode = ''.obs; - final quizMeta = Rx(null); + final quizMeta = Rx(null); final joinedUsers = [].obs; final isAdmin = true.obs; @@ -39,32 +39,44 @@ class WaitingRoomController extends GetxController { sessionCode.value = roomData!.sessionCode; - quizMeta.value = QuizListingModel( - quizId: "q123", - authorId: "a123", - authorName: "Admin", - title: "Uji Coba Kuis", - description: "Kuis untuk testing", - date: DateTime.now().toIso8601String(), - totalQuiz: 5, - duration: 900, - ); + quizMeta.value = data.quizInfo; + + joinedUsers.assignAll(data.sessionInfo.participants); } void _registerSocketListeners() { _socketService.roomMessages.listen((data) { - if (data["type"] == "join") { - final user = data["data"]; - if (user != null) { - joinedUsers.assign(UserModel(id: user['user_id'], name: user['username'])); - CustomNotification.success(title: "Participan Joined", message: "${user['username']} has joined to room"); + if (data["type"] == "participan_join" || data["type"] == "participan_leave") { + joinedUsers.clear(); + + final dataPayload = data["data"]; + final participants = dataPayload["participants"] as List?; + + if (participants != null && participants.isNotEmpty) { + final users = participants.map((e) => UserModel.fromJson(e as Map)).toList(); + + joinedUsers.addAll(users); + + if (data["type"] == "participan_join") { + CustomNotification.success( + title: "Participant Joined", + message: data["message"] ?? "A participant has joined the room.", + ); + } else if (data["type"] == "participan_leave") { + CustomNotification.warning( + title: "Participant Left", + message: data["message"] ?? "A participant has left the room.", + ); + } } } if (data["type"] == "leave") { - final userId = data["data"]; - CustomNotification.warning(title: "Participan Leave", message: "participan leave the room"); - joinedUsers.removeWhere((e) => e.id == userId); + CustomNotification.warning( + title: "Participant Leave", + message: "Participant left the room", + ); + // joinedUsers.removeWhere((e) => e.id == userId); } }); @@ -101,7 +113,11 @@ class WaitingRoomController extends GetxController { } void leaveRoom() async { - _socketService.leaveRoom(sessionId: roomData!.sessionId, userId: _userController.userData!.id); + _socketService.leaveRoom( + sessionId: roomData!.sessionId, + userId: _userController.userData!.id, + username: _userController.userName.value, + ); Get.offAllNamed(AppRoutes.mainPage); await Future.delayed(Duration(seconds: 2)); diff --git a/lib/feature/waiting_room/view/waiting_room_view.dart b/lib/feature/waiting_room/view/waiting_room_view.dart index 66caca3..860110a 100644 --- a/lib/feature/waiting_room/view/waiting_room_view.dart +++ b/lib/feature/waiting_room/view/waiting_room_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/component/global_button.dart'; +import 'package:quiz_app/data/models/quiz/quiz_info_model.dart'; import 'package:quiz_app/data/models/user/user_model.dart'; import 'package:quiz_app/feature/waiting_room/controller/waiting_room_controller.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; @@ -21,7 +22,7 @@ class WaitingRoomView extends GetView { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildQuizMeta(quiz), + _buildQuizMeta(quiz!), const SizedBox(height: 20), _buildSessionCode(context, session), const SizedBox(height: 20), @@ -70,8 +71,8 @@ class WaitingRoomView extends GetView { ); } - Widget _buildQuizMeta(dynamic quiz) { - if (quiz == null) return const SizedBox.shrink(); + Widget _buildQuizMeta(QuizInfo quiz) { + // if (quiz == null) return const SizedBox.shrink(); return Container( padding: const EdgeInsets.all(16), width: double.infinity, @@ -88,7 +89,7 @@ class WaitingRoomView extends GetView { Text("Judul: ${quiz.title}"), Text("Deskripsi: ${quiz.description}"), Text("Jumlah Soal: ${quiz.totalQuiz}"), - Text("Durasi: ${quiz.duration ~/ 60} menit"), + Text("Durasi: ${quiz.limitDuration ~/ 60} menit"), ], ), ); @@ -109,9 +110,9 @@ class WaitingRoomView extends GetView { ), child: Row( children: [ - CircleAvatar(child: Text(user.name[0])), + CircleAvatar(child: Text(user.username[0])), const SizedBox(width: 12), - Text(user.name, style: const TextStyle(fontSize: 16)), + Text(user.username, style: const TextStyle(fontSize: 16)), ], ), ); From 82f4a1ec411fcb3cb8ee41634ecf12f48f339d41 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sat, 17 May 2025 20:57:25 +0700 Subject: [PATCH 063/104] fix: issue on the play quiz controller --- lib/data/services/socket_service.dart | 6 +- .../controller/monitor_quiz_controller.dart | 50 ++++- .../controller/play_quiz_controller.dart | 57 ++++- .../view/play_quiz_multiplayer.dart | 196 ++++++++++++------ .../controller/waiting_room_controller.dart | 8 +- 5 files changed, 233 insertions(+), 84 deletions(-) diff --git a/lib/data/services/socket_service.dart b/lib/data/services/socket_service.dart index 7f39b17..7403c3e 100644 --- a/lib/data/services/socket_service.dart +++ b/lib/data/services/socket_service.dart @@ -119,9 +119,9 @@ class SocketService { }); } - void startQuiz({required String sessionCode}) { + void startQuiz({required String sessionId}) { socket.emit('start_quiz', { - 'session_code': sessionCode, + 'session_id': sessionId, }); } @@ -129,6 +129,7 @@ class SocketService { required String sessionId, required String userId, required int questionIndex, + required int timeSpent, required dynamic answer, }) { socket.emit('submit_answer', { @@ -136,6 +137,7 @@ class SocketService { 'user_id': userId, 'question_index': questionIndex, 'answer': answer, + 'time_spent': timeSpent, }); } diff --git a/lib/feature/monitor_quiz/controller/monitor_quiz_controller.dart b/lib/feature/monitor_quiz/controller/monitor_quiz_controller.dart index f4946d8..62f1c1e 100644 --- a/lib/feature/monitor_quiz/controller/monitor_quiz_controller.dart +++ b/lib/feature/monitor_quiz/controller/monitor_quiz_controller.dart @@ -46,33 +46,58 @@ class MonitorQuizController extends GetxController { _socketService.scoreUpdates.listen((data) { logC.i("📊 Score Update Received: $data"); - final Map scoreMap = Map.from(data); + // Ensure data is a valid map + // if (data is! Map) { + // logC.e("Invalid score update format: $data"); + // return; + // } - scoreMap.forEach((userId, scoreData) { + // Parse the score data more carefully + final List scoreList = data['scores'] ?? []; + + for (var scoreData in scoreList) { + // Safely extract user ID and score information + final String? userId = scoreData['user_id']; + + if (userId == null) { + logC.w("Skipping score update with missing user ID"); + continue; + } + + // Find the index of the participant final index = participan.indexWhere((p) => p.id == userId); if (index != -1) { // Participant found, update their scores final participant = participan[index]; - final correct = scoreData["correct"] ?? 0; - final incorrect = scoreData["incorrect"] ?? 0; + // Safely extract correct and incorrect values, default to 0 + final int correct = scoreData['correct'] ?? 0; + final int incorrect = scoreData['incorrect'] ?? 0; + final int totalScore = scoreData['total_score'] ?? 0; + + // Update participant scores participant.correct.value = correct; participant.wrong.value = incorrect; + participant.totalScore.value = totalScore; // Assuming you have a totalScore observable } else { - // Participant not found, optionally add new participant + // Participant not found, add new participant participan.add( ParticipantAnswerPoint( id: userId, - name: "Unknown", // Or fetch proper name if available - correct: (scoreData["correct"] ?? 0).obs, - wrong: (scoreData["incorrect"] ?? 0).obs, + name: "Unknown", // Consider fetching proper name if possible + correct: (scoreData['correct'] ?? 0).obs, + wrong: (scoreData['incorrect'] ?? 0).obs, + totalScore: (scoreData['total_score'] ?? 0).obs, ), ); } - }); + } - // Notify observers if needed (optional) + // Sort participants by total score (optional) + participan.sort((a, b) => b.totalScore.value.compareTo(a.totalScore.value)); + + // Notify observers participan.refresh(); }); } @@ -83,12 +108,15 @@ class ParticipantAnswerPoint { final String name; final RxInt correct; final RxInt wrong; + final RxInt totalScore; ParticipantAnswerPoint({ required this.id, required this.name, RxInt? correct, RxInt? wrong, + RxInt? totalScore, }) : correct = correct ?? 0.obs, - wrong = wrong ?? 0.obs; + wrong = wrong ?? 0.obs, + totalScore = totalScore ?? 0.obs; } diff --git a/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart b/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart index f0881b7..1a20595 100644 --- a/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart +++ b/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/component/global_button.dart'; @@ -7,21 +8,21 @@ import 'package:quiz_app/data/services/socket_service.dart'; class PlayQuizMultiplayerController extends GetxController { final SocketService _socketService; final UserController _userController; - PlayQuizMultiplayerController(this._socketService, this._userController); - // final questions = [].obs; final Rxn currentQuestion = Rxn(); final currentQuestionIndex = 0.obs; final selectedAnswer = Rxn(); final isDone = false.obs; - final Rx buttonType = ButtonType.disabled.obs; - final fillInAnswerController = TextEditingController(); RxBool isASentAns = false.obs; - late final String sessionCode; + // Timer related variables + final RxInt remainingTime = 0.obs; + Timer? _timer; + + late final String sessionId; late final bool isAdmin; @override @@ -33,14 +34,13 @@ class PlayQuizMultiplayerController extends GetxController { _loadData() { final args = Get.arguments as Map; - sessionCode = args["session_code"]; + sessionId = args["session_id"]; isAdmin = args["is_admin"]; } _registerListener() { fillInAnswerController.addListener(() { final text = fillInAnswerController.text; - if (text.isNotEmpty) { buttonType.value = ButtonType.primary; } else { @@ -52,17 +52,52 @@ class PlayQuizMultiplayerController extends GetxController { buttonType.value = ButtonType.disabled; fillInAnswerController.clear(); isASentAns.value = false; + selectedAnswer.value = null; final model = MultiplayerQuestionModel.fromJson(Map.from(data)); currentQuestion.value = model; - fillInAnswerController.clear(); + + // Start the timer for this question + _startTimer(model.duration); }); _socketService.quizDone.listen((_) { isDone.value = true; + // Cancel timer when quiz is done + _cancelTimer(); }); } + // Start timer with the question duration + void _startTimer(int duration) { + // Cancel any existing timer + _cancelTimer(); + + // Set initial remaining time in seconds + remainingTime.value = duration; + + // Create a timer that ticks every second + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (remainingTime.value > 0) { + remainingTime.value--; + } else { + // Time's up - cancel the timer + _cancelTimer(); + + // Auto-submit if the user hasn't already submitted an answer + if (!isASentAns.value) { + submitAnswer(); + } + } + }); + } + + // Cancel the timer + void _cancelTimer() { + _timer?.cancel(); + _timer = null; + } + void selectOptionAnswer(String option) { selectedAnswer.value = option; buttonType.value = ButtonType.primary; @@ -76,8 +111,8 @@ class PlayQuizMultiplayerController extends GetxController { void submitAnswer() { final question = currentQuestion.value!; final type = question.type; - String? answer; + if (type == 'fill_the_blank') { answer = fillInAnswerController.text.trim(); } else { @@ -86,10 +121,11 @@ class PlayQuizMultiplayerController extends GetxController { if (answer != null && answer.isNotEmpty) { _socketService.sendAnswer( - sessionId: sessionCode, + sessionId: sessionId, userId: _userController.userData!.id, questionIndex: question.questionIndex, answer: answer, + timeSpent: question.duration - remainingTime.value, ); isASentAns.value = true; } @@ -98,6 +134,7 @@ class PlayQuizMultiplayerController extends GetxController { @override void onClose() { fillInAnswerController.dispose(); + _cancelTimer(); // Important: cancel timer when controller is closed super.onClose(); } } diff --git a/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart b/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart index 42faa29..55292a6 100644 --- a/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart +++ b/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart @@ -9,15 +9,7 @@ class PlayQuizMultiplayerView extends GetView { Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFF9FAFB), - appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - centerTitle: true, - title: Text( - "Soal ${(controller.currentQuestion.value?.questionIndex ?? 0)}/10", - style: const TextStyle(color: Colors.black, fontWeight: FontWeight.bold), - ), - ), + // Remove the AppBar and put everything in the body body: Obx(() { if (controller.isDone.value) { return _buildDoneView(); @@ -37,27 +29,110 @@ class PlayQuizMultiplayerView extends GetView { Widget _buildQuestionView() { final question = controller.currentQuestion.value!; - return Padding( - padding: const EdgeInsets.all(20.0), + return SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - question.question, - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black), - ), - const SizedBox(height: 20), - if (question.type == 'option') _buildOptionQuestion(), - if (question.type == 'fill_the_blank') _buildFillInBlankQuestion(), - if (question.type == 'true_false') _buildTrueFalseQuestion(), - const Spacer(), - Obx( - () => GlobalButton( - text: "Kirim jawaban", - onPressed: controller.submitAnswer, - type: controller.buttonType.value, + // Custom AppBar content moved to body + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Back button + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Get.back(), + ), + // Title + Text( + "Soal ${(question.questionIndex + 1)}/10", + style: const TextStyle( + color: Colors.black, + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + // Empty container for spacing + Container(width: 48), + ], ), - ) + ), + + // Timer progress bar + Obx(() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Time remaining text + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + "Waktu tersisa:", + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.black54, + ), + ), + Text( + "${controller.remainingTime.value} detik", + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: controller.remainingTime.value <= 10 ? Colors.red : const Color(0xFF2563EB), + ), + ), + ], + ), + const SizedBox(height: 8), + // Progress bar + LinearProgressIndicator( + value: controller.remainingTime.value / question.duration, + minHeight: 8, + backgroundColor: Colors.grey[300], + valueColor: AlwaysStoppedAnimation( + controller.remainingTime.value <= 10 ? Colors.red : const Color(0xFF2563EB), + ), + borderRadius: BorderRadius.circular(4), + ), + ], + ), + ); + }), + + const SizedBox(height: 20), + + // Question content + Expanded( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + question.question, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black), + ), + const SizedBox(height: 20), + if (question.type == 'option') _buildOptionQuestion(), + if (question.type == 'fill_the_blank') _buildFillInBlankQuestion(), + if (question.type == 'true_false') _buildTrueFalseQuestion(), + const Spacer(), + Obx( + () => GlobalButton( + text: "Kirim jawaban", + onPressed: controller.submitAnswer, + type: controller.buttonType.value, + ), + ) + ], + ), + ), + ), ], ), ); @@ -129,40 +204,45 @@ class PlayQuizMultiplayerView extends GetView { } Widget _buildDoneView() { - return Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text( - "Kuis telah selesai!", - style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: () { - // Arahkan ke halaman hasil atau leaderboard - }, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF2563EB), - foregroundColor: Colors.white, - minimumSize: const Size(double.infinity, 50), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.check_circle, + size: 80, + color: Color(0xFF2563EB), ), - child: const Text("Lihat Hasil", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), - ), - ], + const SizedBox(height: 20), + const Text( + "Kuis telah selesai!", + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + const Text( + "Terima kasih telah berpartisipasi.", + style: TextStyle(fontSize: 16, color: Colors.black54), + textAlign: TextAlign.center, + ), + const SizedBox(height: 40), + ElevatedButton( + onPressed: () { + // Arahkan ke halaman hasil atau leaderboard + Get.back(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF2563EB), + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: const Text("Lihat Hasil", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + ], + ), ), ); } - - // Widget _buildProgressBar() { - // final question = controller.currentQuestion; - // return LinearProgressIndicator( - // value: controller.timeLeft.value / question.duration, - // minHeight: 8, - // backgroundColor: Colors.grey[300], - // valueColor: const AlwaysStoppedAnimation(Color(0xFF2563EB)), - // ); - // } } diff --git a/lib/feature/waiting_room/controller/waiting_room_controller.dart b/lib/feature/waiting_room/controller/waiting_room_controller.dart index 6295739..2ec10e9 100644 --- a/lib/feature/waiting_room/controller/waiting_room_controller.dart +++ b/lib/feature/waiting_room/controller/waiting_room_controller.dart @@ -16,6 +16,7 @@ class WaitingRoomController extends GetxController { WaitingRoomController(this._socketService, this._userController); final sessionCode = ''.obs; + String sessionId = ''; final quizMeta = Rx(null); final joinedUsers = [].obs; final isAdmin = true.obs; @@ -38,6 +39,7 @@ class WaitingRoomController extends GetxController { isAdmin.value = data.isAdmin; sessionCode.value = roomData!.sessionCode; + sessionId = roomData!.sessionId; quizMeta.value = data.quizInfo; @@ -85,7 +87,7 @@ class WaitingRoomController extends GetxController { Get.snackbar("Info", "Kuis telah dimulai"); if (!isAdmin.value) { Get.offAllNamed(AppRoutes.playQuizMPLPage, arguments: { - "session_code": sessionCode.value, + "session_id": sessionId, "is_admin": isAdmin.value, }); } @@ -104,9 +106,9 @@ class WaitingRoomController extends GetxController { } void startQuiz() { - _socketService.startQuiz(sessionCode: sessionCode.value); + _socketService.startQuiz(sessionId: sessionId); Get.offAllNamed(AppRoutes.monitorQuizMPLPage, arguments: { - "session_code": sessionCode.value, + "session_id": sessionId, "is_admin": isAdmin.value, "list_participan": joinedUsers.toList(), }); From 737f0f775a744429a41502958f407b21cedee250 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 18 May 2025 02:30:54 +0700 Subject: [PATCH 064/104] feat: adding admin result page --- lib/app/app.dart | 2 +- lib/app/const/colors/app_colors.dart | 5 + lib/app/routes/app_pages.dart | 5 + lib/app/routes/app_routes.dart | 1 + .../view/admin_result_page.dart | 263 ++++++++++++ .../view/detail_participant_result_page.dart | 238 +++++++++++ .../monitor_quiz/view/monitor_quiz_view.dart | 381 ++++++++++++------ .../view/play_quiz_multiplayer.dart | 115 +++--- pubspec.lock | 8 + pubspec.yaml | 1 + 10 files changed, 840 insertions(+), 179 deletions(-) create mode 100644 lib/feature/admin_result_page/view/admin_result_page.dart create mode 100644 lib/feature/admin_result_page/view/detail_participant_result_page.dart diff --git a/lib/app/app.dart b/lib/app/app.dart index e1cf154..a2c3a11 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -16,7 +16,7 @@ class MyApp extends StatelessWidget { localizationsDelegates: context.localizationDelegates, supportedLocales: context.supportedLocales, initialBinding: InitialBindings(), - initialRoute: AppRoutes.splashScreen, + initialRoute: AppRoutes.monitorResultMPLPage, getPages: AppPages.routes, ); } diff --git a/lib/app/const/colors/app_colors.dart b/lib/app/const/colors/app_colors.dart index 2e8cf87..203044e 100644 --- a/lib/app/const/colors/app_colors.dart +++ b/lib/app/const/colors/app_colors.dart @@ -11,4 +11,9 @@ class AppColors { static const Color shadowPrimary = Color(0x330052CC); static const Color disabledBackground = Color(0xFFE0E0E0); static const Color disabledText = Color(0xFF9E9E9E); + + static const Color scoreExcellent = Color(0xFF36B37E); + static const Color scoreGood = Color(0xFF00B8D9); + static const Color scoreAverage = Color(0xFFFF991F); + static const Color scorePoor = Color(0xFFFF5630); } diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index ea622f2..0713c02 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -37,6 +37,7 @@ import 'package:quiz_app/feature/search/binding/search_binding.dart'; import 'package:quiz_app/feature/splash_screen/presentation/splash_screen_page.dart'; import 'package:quiz_app/feature/waiting_room/binding/waiting_room_binding.dart'; import 'package:quiz_app/feature/waiting_room/view/waiting_room_view.dart'; +import 'package:quiz_app/feature/admin_result_page/view/admin_result_page.dart'; part 'app_routes.dart'; @@ -135,5 +136,9 @@ class AppPages { page: () => PlayQuizMultiplayerView(), binding: PlayQuizMultiplayerBinding(), ), + GetPage( + name: AppRoutes.monitorResultMPLPage, + page: () => AdminResultPage(), + ) ]; } diff --git a/lib/app/routes/app_routes.dart b/lib/app/routes/app_routes.dart index 3274125..3410b5d 100644 --- a/lib/app/routes/app_routes.dart +++ b/lib/app/routes/app_routes.dart @@ -24,4 +24,5 @@ abstract class AppRoutes { static const playQuizMPLPage = "/room/quiz/play"; static const monitorQuizMPLPage = "/room/quiz/monitor"; + static const monitorResultMPLPage = "/room/quiz/monitor/result"; } diff --git a/lib/feature/admin_result_page/view/admin_result_page.dart b/lib/feature/admin_result_page/view/admin_result_page.dart new file mode 100644 index 0000000..1232724 --- /dev/null +++ b/lib/feature/admin_result_page/view/admin_result_page.dart @@ -0,0 +1,263 @@ +// import 'package:flutter/material.dart'; +// import 'package:lucide_icons/lucide_icons.dart'; +// import 'dart:math' as math; + +// import 'package:quiz_app/app/const/colors/app_colors.dart'; +// import 'package:quiz_app/app/const/text/text_style.dart'; +// import 'package:quiz_app/feature/admin_result_page/view/detail_participant_result_page.dart'; + +// class AdminResultPage extends StatelessWidget { +// const AdminResultPage({Key? key}) : super(key: key); + +// @override +// Widget build(BuildContext context) { +// return Scaffold( +// backgroundColor: AppColors.background, +// body: SafeArea( +// child: Padding( +// padding: const EdgeInsets.all(16.0), +// child: Column( +// crossAxisAlignment: CrossAxisAlignment.start, +// children: [ +// _buildSectionHeader("Hasil Akhir Kuis"), +// _buildSummaryCard(), +// const SizedBox(height: 20), +// _buildSectionHeader('Peringkat Peserta'), +// const SizedBox(height: 14), +// Expanded( +// child: ListView.separated( +// itemCount: dummyParticipants.length, +// separatorBuilder: (context, index) => const SizedBox(height: 10), +// itemBuilder: (context, index) { +// final participant = dummyParticipants[index]; +// return _buildParticipantResultCard( +// context, +// participant, +// position: index + 1, +// ); +// }, +// ), +// ), +// ], +// ), +// ), +// ), +// ); +// } + +// Widget _buildSectionHeader(String title) { +// return Container( +// padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), +// decoration: BoxDecoration( +// borderRadius: BorderRadius.circular(8), +// ), +// child: Text(title, style: AppTextStyles.title), +// ); +// } + +// Widget _buildSummaryCard() { +// // Hitung nilai rata-rata +// final avgScore = dummyParticipants.map((p) => p.scorePercent).reduce((a, b) => a + b) / dummyParticipants.length; + +// // Hitung jumlah peserta yang lulus (>= 60%) +// final passCount = dummyParticipants.where((p) => p.scorePercent >= 60).length; + +// return Card( +// elevation: 2, +// color: Colors.white, +// shadowColor: AppColors.shadowPrimary.withOpacity(0.2), +// shape: RoundedRectangleBorder( +// borderRadius: BorderRadius.circular(16), +// side: BorderSide(color: AppColors.accentBlue.withOpacity(0.2)), +// ), +// child: Padding( +// padding: const EdgeInsets.all(20), +// child: Column( +// crossAxisAlignment: CrossAxisAlignment.start, +// children: [ +// Row( +// children: [ +// Icon( +// LucideIcons.clipboardCheck, +// color: AppColors.primaryBlue, +// size: 20, +// ), +// const SizedBox(width: 8), +// Text( +// "RINGKASAN KUIS", +// style: TextStyle( +// fontSize: 13, +// fontWeight: FontWeight.bold, +// color: AppColors.primaryBlue, +// letterSpacing: 0.8, +// ), +// ), +// ], +// ), +// Divider(color: AppColors.borderLight, height: 20), +// Row( +// mainAxisAlignment: MainAxisAlignment.spaceAround, +// children: [ +// _buildSummaryItem( +// icon: LucideIcons.users, +// value: "${dummyParticipants.length}", +// label: "Total Peserta", +// ), +// _buildSummaryItem( +// icon: LucideIcons.percent, +// value: "${avgScore.toStringAsFixed(1)}%", +// label: "Rata-Rata Nilai", +// valueColor: _getScoreColor(avgScore), +// ), +// _buildSummaryItem( +// icon: LucideIcons.award, +// value: "$passCount/${dummyParticipants.length}", +// label: "Peserta Lulus", +// valueColor: AppColors.scoreGood, +// ), +// ], +// ), +// ], +// ), +// ), +// ); +// } + +// Widget _buildSummaryItem({ +// required IconData icon, +// required String value, +// required String label, +// Color? valueColor, +// }) { +// return Column( +// children: [ +// Icon(icon, color: AppColors.softGrayText, size: 22), +// const SizedBox(height: 8), +// Text( +// value, +// style: TextStyle( +// fontSize: 18, +// fontWeight: FontWeight.bold, +// color: valueColor ?? AppColors.darkText, +// ), +// ), +// const SizedBox(height: 4), +// Text( +// label, +// style: AppTextStyles.caption, +// ), +// ], +// ); +// } + +// Widget _buildParticipantResultCard(BuildContext context, ParticipantResult participant, {required int position}) { +// return InkWell( +// onTap: () { +// // Navigasi ke halaman detail saat kartu ditekan +// Navigator.push( +// context, +// MaterialPageRoute( +// builder: (context) => ParticipantDetailPage(participant: participant), +// ), +// ); +// }, +// child: Card( +// elevation: 2, +// color: Colors.white, +// shadowColor: AppColors.shadowPrimary.withOpacity(0.2), +// shape: RoundedRectangleBorder( +// borderRadius: BorderRadius.circular(16), +// side: BorderSide(color: AppColors.accentBlue.withOpacity(0.2)), +// ), +// child: Padding( +// padding: const EdgeInsets.all(20), +// child: Row( +// children: [ +// // Position indicator +// Container( +// width: 36, +// height: 36, +// decoration: BoxDecoration( +// shape: BoxShape.circle, +// color: _getPositionColor(position), +// ), +// child: Center( +// child: Text( +// position.toString(), +// style: const TextStyle( +// fontSize: 16, +// fontWeight: FontWeight.bold, +// color: Colors.white, +// ), +// ), +// ), +// ), +// const SizedBox(width: 16), +// // Participant info +// Expanded( +// child: Column( +// crossAxisAlignment: CrossAxisAlignment.start, +// children: [ +// Text( +// participant.name, +// style: AppTextStyles.subtitle, +// ), +// const SizedBox(height: 4), +// Text( +// "Benar: ${participant.correctAnswers}/${participant.totalQuestions}", +// style: AppTextStyles.caption, +// ), +// ], +// ), +// ), +// // Score +// Container( +// width: 60, +// height: 60, +// decoration: BoxDecoration( +// shape: BoxShape.circle, +// color: _getScoreColor(participant.scorePercent).withOpacity(0.1), +// border: Border.all( +// color: _getScoreColor(participant.scorePercent), +// width: 2, +// ), +// ), +// child: Center( +// child: Text( +// "${participant.scorePercent.toInt()}%", +// style: TextStyle( +// fontSize: 16, +// fontWeight: FontWeight.bold, +// color: _getScoreColor(participant.scorePercent), +// ), +// ), +// ), +// ), +// const SizedBox(width: 12), +// // Arrow indicator +// Icon( +// LucideIcons.chevronRight, +// color: AppColors.softGrayText, +// size: 20, +// ), +// ], +// ), +// ), +// ), +// ); +// } + +// Color _getScoreColor(double score) { +// if (score >= 80) return AppColors.scoreExcellent; +// if (score >= 70) return AppColors.scoreGood; +// if (score >= 60) return AppColors.scoreAverage; +// return AppColors.scorePoor; +// } + +// Color _getPositionColor(int position) { +// if (position == 1) return const Color(0xFFFFD700); // Gold +// if (position == 2) return const Color(0xFFC0C0C0); // Silver +// if (position == 3) return const Color(0xFFCD7F32); // Bronze +// return AppColors.softGrayText; +// } +// } diff --git a/lib/feature/admin_result_page/view/detail_participant_result_page.dart b/lib/feature/admin_result_page/view/detail_participant_result_page.dart new file mode 100644 index 0000000..967011f --- /dev/null +++ b/lib/feature/admin_result_page/view/detail_participant_result_page.dart @@ -0,0 +1,238 @@ +// // Halaman detail untuk peserta +// import 'package:flutter/material.dart'; +// import 'package:lucide_icons/lucide_icons.dart'; +// import 'package:quiz_app/app/const/colors/app_colors.dart'; +// import 'package:quiz_app/app/const/text/text_style.dart'; +// import 'package:quiz_app/feature/admin_result_page/view/admin_result_page.dart'; + +// class ParticipantDetailPage extends StatelessWidget { +// final ParticipantResult participant; + +// const ParticipantDetailPage({ +// Key? key, +// required this.participant, +// }) : super(key: key); + +// @override +// Widget build(BuildContext context) { +// return Scaffold( +// backgroundColor: AppColors.background, +// appBar: AppBar( +// title: Text('Detail ${participant.name}'), +// backgroundColor: Colors.white, +// foregroundColor: AppColors.darkText, +// elevation: 0, +// leading: IconButton( +// icon: const Icon(LucideIcons.arrowLeft), +// onPressed: () => Navigator.pop(context), +// ), +// ), +// body: Column( +// children: [ +// _buildParticipantHeader(), +// Expanded( +// child: ListView.builder( +// padding: const EdgeInsets.all(16), +// itemCount: participant.answers.length, +// itemBuilder: (context, index) { +// return _buildAnswerCard(participant.answers[index], index + 1); +// }, +// ), +// ), +// ], +// ), +// ); +// } + +// Widget _buildParticipantHeader() { +// return Container( +// width: double.infinity, +// padding: const EdgeInsets.all(16), +// decoration: BoxDecoration( +// color: Colors.white, +// border: Border( +// bottom: BorderSide( +// color: AppColors.borderLight, +// width: 1, +// ), +// ), +// ), +// child: Row( +// children: [ +// CircleAvatar( +// radius: 26, +// backgroundColor: AppColors.accentBlue, +// child: Text( +// participant.name.isNotEmpty ? participant.name[0].toUpperCase() : "?", +// style: TextStyle( +// fontSize: 22, +// fontWeight: FontWeight.bold, +// color: AppColors.primaryBlue, +// ), +// ), +// ), +// const SizedBox(width: 16), +// Expanded( +// child: Column( +// crossAxisAlignment: CrossAxisAlignment.start, +// children: [ +// Text( +// participant.name, +// style: TextStyle( +// fontSize: 16, +// fontWeight: FontWeight.bold, +// color: AppColors.darkText, +// ), +// ), +// const SizedBox(height: 4), +// Text( +// "Jumlah Soal: ${participant.totalQuestions}", +// style: TextStyle( +// fontSize: 14, +// color: AppColors.softGrayText, +// ), +// ), +// ], +// ), +// ), +// Container( +// padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), +// decoration: BoxDecoration( +// color: _getScoreColor(participant.scorePercent).withOpacity(0.1), +// borderRadius: BorderRadius.circular(16), +// border: Border.all( +// color: _getScoreColor(participant.scorePercent), +// ), +// ), +// child: Row( +// children: [ +// Icon( +// LucideIcons.percent, +// size: 16, +// color: _getScoreColor(participant.scorePercent), +// ), +// const SizedBox(width: 6), +// Text( +// "${participant.scorePercent.toInt()}%", +// style: TextStyle( +// fontSize: 16, +// fontWeight: FontWeight.bold, +// color: _getScoreColor(participant.scorePercent), +// ), +// ), +// ], +// ), +// ), +// ], +// ), +// ); +// } + +// Widget _buildAnswerCard(QuestionAnswer answer, int number) { +// return Container( +// width: double.infinity, +// margin: const EdgeInsets.only(bottom: 20), +// padding: const EdgeInsets.all(16), +// // decoration: _containerDecoration, +// child: Column( +// crossAxisAlignment: CrossAxisAlignment.start, +// children: [ +// Row( +// crossAxisAlignment: CrossAxisAlignment.start, +// children: [ +// Container( +// width: 28, +// height: 28, +// decoration: BoxDecoration( +// shape: BoxShape.circle, +// color: answer.isCorrect ? AppColors.primaryBlue.withOpacity(0.1) : AppColors.scorePoor.withOpacity(0.1), +// ), +// child: Center( +// child: Text( +// number.toString(), +// style: TextStyle( +// fontSize: 13, +// fontWeight: FontWeight.bold, +// color: answer.isCorrect ? AppColors.primaryBlue : AppColors.scorePoor, +// ), +// ), +// ), +// ), +// const SizedBox(width: 12), +// Expanded( +// child: Text( +// 'Soal $number: ${answer.question}', +// style: const TextStyle( +// fontSize: 16, +// fontWeight: FontWeight.bold, +// color: AppColors.darkText, +// ), +// ), +// ), +// Icon( +// answer.isCorrect ? LucideIcons.checkCircle : LucideIcons.xCircle, +// color: answer.isCorrect ? AppColors.primaryBlue : AppColors.scorePoor, +// size: 20, +// ), +// ], +// ), +// const SizedBox(height: 12), +// Divider(color: AppColors.borderLight), +// const SizedBox(height: 12), +// _buildAnswerRow( +// label: "Jawaban Siswa:", +// answer: answer.userAnswer, +// isCorrect: answer.isCorrect, +// ), +// if (!answer.isCorrect) ...[ +// const SizedBox(height: 10), +// _buildAnswerRow( +// label: "Jawaban Benar:", +// answer: answer.correctAnswer, +// isCorrect: true, +// ), +// ], +// ], +// ), +// ); +// } + +// Widget _buildAnswerRow({ +// required String label, +// required String answer, +// required bool isCorrect, +// }) { +// return Row( +// crossAxisAlignment: CrossAxisAlignment.start, +// children: [ +// SizedBox( +// width: 110, +// child: Text( +// label, +// style: TextStyle( +// fontSize: 14, +// fontWeight: FontWeight.w500, +// color: AppColors.softGrayText, +// ), +// ), +// ), +// Expanded( +// child: Text( +// answer, +// style: TextStyle( +// fontSize: 15, +// fontWeight: FontWeight.w600, +// color: isCorrect ? AppColors.primaryBlue : AppColors.scorePoor, +// ), +// ), +// ), +// ], +// ); +// } + +// Color _getScoreColor(double score) { +// if (score >= 70) return AppColors.scoreGood; +// if (score >= 60) return AppColors.scoreAverage; +// return AppColors.scorePoor; +// } +// } diff --git a/lib/feature/monitor_quiz/view/monitor_quiz_view.dart b/lib/feature/monitor_quiz/view/monitor_quiz_view.dart index 1c09fb7..32f5570 100644 --- a/lib/feature/monitor_quiz/view/monitor_quiz_view.dart +++ b/lib/feature/monitor_quiz/view/monitor_quiz_view.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:lucide_icons/lucide_icons.dart'; +import 'package:percent_indicator/linear_percent_indicator.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/app/const/text/text_style.dart'; import 'package:quiz_app/feature/monitor_quiz/controller/monitor_quiz_controller.dart'; class MonitorQuizView extends GetView { @@ -9,32 +12,115 @@ class MonitorQuizView extends GetView { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Monitor Quiz')), - body: Padding( - padding: const EdgeInsets.all(16.0), + backgroundColor: AppColors.background, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader("Monitor Admin"), + Obx(() => _buildCurrentQuestion( + questionText: controller.currentQuestion.value, + )), + const SizedBox(height: 24), + _buildSectionHeader('Daftar Peserta'), + const SizedBox(height: 16), + Expanded( + child: Obx( + () => ListView.separated( + itemCount: controller.participan.length, + separatorBuilder: (context, index) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final student = controller.participan[index]; + final totalAnswers = student.correct.value + student.wrong.value; + final progressPercent = totalAnswers > 0 ? student.correct.value / totalAnswers : 0.0; + + return _buildStudentCard( + name: student.name, + totalBenar: student.correct.value, + totalSalah: student.wrong.value, + progressPercent: progressPercent, + ); + }, + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildSectionHeader(String title) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + ), + child: Text(title, style: AppTextStyles.title), + ); + } + + Widget _buildCurrentQuestion({required String questionText}) { + return Card( + elevation: 2, + shadowColor: AppColors.shadowPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.accentBlue, width: 1), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Obx( - () => _buildCurrentQuestion(questionText: controller.currentQuestion.value), - ), - const SizedBox(height: 24), - const Text( - 'Daftar Peserta:', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 16), - Obx( - () => ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: controller.participan.length, - itemBuilder: (context, index) => _buildUserRow( - name: controller.participan[index].name, - totalBenar: controller.participan[index].correct.value, - totalSalah: controller.participan[index].wrong.value, + Row( + children: [ + Icon( + LucideIcons.activity, + color: AppColors.primaryBlue, + size: 18, ), - ), + const SizedBox(width: 8), + Text( + "PERTANYAAN AKTIF", + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: AppColors.primaryBlue, + letterSpacing: 0.8, + ), + ), + ], + ), + Divider(color: AppColors.borderLight, height: 20), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + LucideIcons.helpCircle, + color: AppColors.darkText, + size: 22, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + questionText, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: AppColors.darkText, + ), + ), + ), + ], ), ], ), @@ -42,46 +128,149 @@ class MonitorQuizView extends GetView { ); } - Widget _buildCurrentQuestion({required String questionText}) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.blue.shade50, - border: Border.all(color: Colors.blue), - borderRadius: BorderRadius.circular(12), + Widget _buildStudentCard({ + required String name, + required int totalBenar, + required int totalSalah, + required double progressPercent, + }) { + final int totalJawaban = totalBenar + totalSalah; + + return Card( + elevation: 2, + color: Colors.white, + shadowColor: AppColors.shadowPrimary.withOpacity(0.2), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: AppColors.accentBlue.withOpacity(0.2)), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "pertanyaan sekarang", - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.blue, - ), - ), - SizedBox( - height: 10, - ), - Row( - children: [ - const Icon( - LucideIcons.helpCircle, - color: Colors.blue, - size: 24, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - questionText, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.blue, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + radius: 28, + backgroundColor: AppColors.accentBlue, + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : "?", + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: AppColors.primaryBlue, + ), ), ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.darkText, + ), + ), + const SizedBox(height: 4), + Text( + 'Total Jawaban: $totalJawaban', + style: TextStyle( + fontSize: 14, + color: AppColors.softGrayText, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + LinearPercentIndicator( + animation: true, + animationDuration: 600, + lineHeight: 12.0, + percent: progressPercent, + center: Text( + "${(progressPercent * 100).toInt()}%", + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + barRadius: const Radius.circular(8), + progressColor: _getProgressColor(progressPercent), + backgroundColor: AppColors.disabledBackground, + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: _buildStatCard( + icon: LucideIcons.checkCircle, + color: const Color(0xFF36B37E), + label: "Benar", + value: totalBenar, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildStatCard( + icon: LucideIcons.xCircle, + color: const Color(0xFFFF5630), + label: "Salah", + value: totalSalah, + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildStatCard({ + required IconData icon, + required Color color, + required String label, + required int value, + }) { + return Container( + width: 130, + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + decoration: BoxDecoration( + color: color.withOpacity(0.08), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withOpacity(0.2), width: 1), + ), + child: Row( + children: [ + Icon(icon, color: color, size: 20), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + color: AppColors.softGrayText, + ), + ), + Text( + value.toString(), + style: TextStyle( + fontSize: 18, + color: color, + fontWeight: FontWeight.bold, + ), ), ], ), @@ -90,75 +279,9 @@ class MonitorQuizView extends GetView { ); } - Widget _buildUserRow({ - required String name, - required int totalBenar, - required int totalSalah, - }) { - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Container( - width: 60, - height: 60, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Colors.grey.shade300, - ), - child: const Icon( - Icons.person, - size: 30, - color: Colors.white, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - name, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Row( - children: [ - const Icon( - LucideIcons.checkCircle, - color: Colors.green, - size: 18, - ), - const SizedBox(width: 4), - Text( - 'Benar: $totalBenar', - style: const TextStyle(color: Colors.green), - ), - const SizedBox(width: 16), - const Icon( - LucideIcons.xCircle, - color: Colors.red, - size: 18, - ), - const SizedBox(width: 4), - Text( - 'Salah: $totalSalah', - style: const TextStyle(color: Colors.red), - ), - ], - ), - ], - ), - ), - ], - ), - ); + Color _getProgressColor(double percent) { + if (percent < 0.4) return const Color(0xFFFF5630); // Red + if (percent < 0.7) return const Color(0xFFFF991F); // Orange + return const Color(0xFF36B37E); // Green } } diff --git a/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart b/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart index 55292a6..9368abb 100644 --- a/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart +++ b/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart @@ -9,7 +9,6 @@ class PlayQuizMultiplayerView extends GetView { Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFF9FAFB), - // Remove the AppBar and put everything in the body body: Obx(() { if (controller.isDone.value) { return _buildDoneView(); @@ -19,9 +18,6 @@ class PlayQuizMultiplayerView extends GetView { return const Center(child: CircularProgressIndicator()); } - if (controller.isASentAns.value) { - return const Center(child: Text("you already answer, please wait until the duration is done")); - } return _buildQuestionView(); }), ); @@ -36,30 +32,16 @@ class PlayQuizMultiplayerView extends GetView { // Custom AppBar content moved to body Padding( padding: const EdgeInsets.all(16.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - // Back button - IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => Get.back(), - ), - // Title - Text( - "Soal ${(question.questionIndex + 1)}/10", - style: const TextStyle( - color: Colors.black, - fontWeight: FontWeight.bold, - fontSize: 18, - ), - ), - // Empty container for spacing - Container(width: 48), - ], + child: Text( + "Soal ${(question.questionIndex)}/10", + style: const TextStyle( + color: Colors.black, + fontWeight: FontWeight.bold, + fontSize: 24, + ), ), ), - // Timer progress bar Obx(() { return Padding( padding: const EdgeInsets.symmetric(horizontal: 20.0), @@ -105,34 +87,69 @@ class PlayQuizMultiplayerView extends GetView { }), const SizedBox(height: 20), + Obx(() { + if (controller.isASentAns.value) { + return Container( + padding: const EdgeInsets.all(20), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Add a nice loading animation - // Question content - Expanded( - child: Padding( - padding: const EdgeInsets.all(20.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - question.question, - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black), + const SizedBox(height: 24), + // Improved text with better styling + const Text( + "Jawaban Anda telah terkirim", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.blue, + ), + ), + const SizedBox(height: 8), + // Informative subtext + const Text( + "Mohon tunggu soal selanjutnya", + style: TextStyle( + fontSize: 14, + color: Colors.grey, + ), + ), + ], ), - const SizedBox(height: 20), - if (question.type == 'option') _buildOptionQuestion(), - if (question.type == 'fill_the_blank') _buildFillInBlankQuestion(), - if (question.type == 'true_false') _buildTrueFalseQuestion(), - const Spacer(), - Obx( - () => GlobalButton( - text: "Kirim jawaban", - onPressed: controller.submitAnswer, - type: controller.buttonType.value, + ), + ); + } + + return Expanded( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + question.question, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black), ), - ) - ], + const SizedBox(height: 20), + if (question.type == 'option') _buildOptionQuestion(), + if (question.type == 'fill_the_blank') _buildFillInBlankQuestion(), + if (question.type == 'true_false') _buildTrueFalseQuestion(), + const Spacer(), + Obx( + () => GlobalButton( + text: "Kirim jawaban", + onPressed: controller.submitAnswer, + type: controller.buttonType.value, + ), + ) + ], + ), ), - ), - ), + ); + }), + // Question content ], ), ); diff --git a/pubspec.lock b/pubspec.lock index f409f2b..6045306 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -477,6 +477,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + percent_indicator: + dependency: "direct main" + description: + name: percent_indicator + sha256: "157d29133bbc6ecb11f923d36e7960a96a3f28837549a20b65e5135729f0f9fd" + url: "https://pub.dev" + source: hosted + version: "4.2.5" platform: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index c5c13c3..0c07a01 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,7 @@ dependencies: google_fonts: ^6.1.0 socket_io_client: ^3.1.2 easy_localization: ^3.0.7+1 + percent_indicator: ^4.2.5 dev_dependencies: flutter_test: From d925a22bb00fa35d77ccfd587e09c1c9f4be8c74 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 18 May 2025 15:00:55 +0700 Subject: [PATCH 065/104] feat: adding create automatic quiz --- assets/translations/en-US.json | 4 +- assets/translations/id-ID.json | 3 +- assets/translations/ms-MY.json | 4 +- lib/app/app.dart | 2 +- lib/app/routes/app_pages.dart | 9 ++- lib/core/endpoint/api_endpoint.dart | 1 + lib/data/services/quiz_service.dart | 50 +++++++++++++++ .../binding/quiz_creation_binding.dart | 8 ++- .../controller/quiz_creation_controller.dart | 61 +++++++++++++++++++ .../component/custom_question_component.dart | 7 +++ .../view/component/generate_component.dart | 49 ++++----------- .../view/quiz_creation_view.dart | 6 -- 12 files changed, 152 insertions(+), 52 deletions(-) diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index b239b4b..9eb7d68 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -82,5 +82,7 @@ "save_quiz": "Save Quiz", "select_language": "Select Language", - "change_language": "Change Language" + "change_language": "Change Language", + + "auto_generate_quiz": "Auto Generate Quiz" } diff --git a/assets/translations/id-ID.json b/assets/translations/id-ID.json index 345ee7d..cef3637 100644 --- a/assets/translations/id-ID.json +++ b/assets/translations/id-ID.json @@ -82,5 +82,6 @@ "save_quiz": "Simpan Kuis", "select_language": "Pilih Bahasa", - "change_language": "Ganti Bahasa" + "change_language": "Ganti Bahasa", + "auto_generate_quiz": "Buat Kuis Otomatis" } diff --git a/assets/translations/ms-MY.json b/assets/translations/ms-MY.json index a5ec604..9cbc487 100644 --- a/assets/translations/ms-MY.json +++ b/assets/translations/ms-MY.json @@ -79,5 +79,7 @@ "quiz_description_label": "Deskripsi Ringkas", "quiz_subject_label": "Subjek", "make_quiz_public": "Jadikan Kuiz Umum", - "save_quiz": "Simpan Kuiz" + "save_quiz": "Simpan Kuiz", + + "auto_generate_quiz": "Jana Kuiz Automatik" } diff --git a/lib/app/app.dart b/lib/app/app.dart index a2c3a11..e1cf154 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -16,7 +16,7 @@ class MyApp extends StatelessWidget { localizationsDelegates: context.localizationDelegates, supportedLocales: context.supportedLocales, initialBinding: InitialBindings(), - initialRoute: AppRoutes.monitorResultMPLPage, + initialRoute: AppRoutes.splashScreen, getPages: AppPages.routes, ); } diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index 0713c02..692f6e5 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -37,7 +37,6 @@ import 'package:quiz_app/feature/search/binding/search_binding.dart'; import 'package:quiz_app/feature/splash_screen/presentation/splash_screen_page.dart'; import 'package:quiz_app/feature/waiting_room/binding/waiting_room_binding.dart'; import 'package:quiz_app/feature/waiting_room/view/waiting_room_view.dart'; -import 'package:quiz_app/feature/admin_result_page/view/admin_result_page.dart'; part 'app_routes.dart'; @@ -136,9 +135,9 @@ class AppPages { page: () => PlayQuizMultiplayerView(), binding: PlayQuizMultiplayerBinding(), ), - GetPage( - name: AppRoutes.monitorResultMPLPage, - page: () => AdminResultPage(), - ) + // GetPage( + // name: AppRoutes.monitorResultMPLPage, + // page: () => AdminResultPage(), + // ) ]; } diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart index 2188098..cab3205 100644 --- a/lib/core/endpoint/api_endpoint.dart +++ b/lib/core/endpoint/api_endpoint.dart @@ -9,6 +9,7 @@ class APIEndpoint { static const String register = "/register"; static const String quiz = "/quiz"; + static const String quizGenerate = "/quiz/ai"; static const String quizAnswer = "/quiz/answer"; static const String userQuiz = "/quiz/user"; diff --git a/lib/data/services/quiz_service.dart b/lib/data/services/quiz_service.dart index b92358d..c1146fe 100644 --- a/lib/data/services/quiz_service.dart +++ b/lib/data/services/quiz_service.dart @@ -35,6 +35,32 @@ class QuizService extends GetxService { } } + Future>> createQuizAuto(String sentence) async { + try { + final response = await _dio.post( + APIEndpoint.quizGenerate, + data: {"sentence": sentence}, + ); + + if (response.statusCode == 200) { + print(response.data); + + // Parsing response using BaseResponseModel + final parseResponse = BaseResponseModel>.fromJson( + response.data, + (data) => (data as List).map((item) => RawQuizModel.fromJson(item as Map)).toList(), + ); + + return parseResponse; + } else { + throw Exception("Quiz creation failed with status: ${response.statusCode}"); + } + } catch (e) { + logC.e("Quiz creation error: $e"); + throw Exception("Quiz creation error: $e"); + } + } + Future>?> userQuiz(String userId, int page) async { try { final response = await _dio.get("${APIEndpoint.userQuiz}/$userId?page=$page"); @@ -122,3 +148,27 @@ class QuizService extends GetxService { } } } + +class RawQuizModel { + final String qustion; + final dynamic answer; + + RawQuizModel({ + required this.qustion, + required this.answer, + }); + + factory RawQuizModel.fromJson(Map json) { + return RawQuizModel( + qustion: json['qustion'] as String, + answer: json['answer'], + ); + } + + Map toJson() { + return { + 'qustion': qustion, + 'answer': answer, + }; + } +} diff --git a/lib/feature/quiz_creation/binding/quiz_creation_binding.dart b/lib/feature/quiz_creation/binding/quiz_creation_binding.dart index 4c3154a..8fd8031 100644 --- a/lib/feature/quiz_creation/binding/quiz_creation_binding.dart +++ b/lib/feature/quiz_creation/binding/quiz_creation_binding.dart @@ -1,9 +1,15 @@ import "package:get/get.dart"; +import "package:quiz_app/data/services/quiz_service.dart"; import "package:quiz_app/feature/quiz_creation/controller/quiz_creation_controller.dart"; class QuizCreationBinding extends Bindings { @override void dependencies() { - Get.lazyPut(() => QuizCreationController()); + Get.lazyPut(() => QuizService()); + Get.lazyPut( + () => QuizCreationController( + Get.find(), + ), + ); } } diff --git a/lib/feature/quiz_creation/controller/quiz_creation_controller.dart b/lib/feature/quiz_creation/controller/quiz_creation_controller.dart index 5d72a76..d4a3914 100644 --- a/lib/feature/quiz_creation/controller/quiz_creation_controller.dart +++ b/lib/feature/quiz_creation/controller/quiz_creation_controller.dart @@ -5,9 +5,18 @@ import 'package:quiz_app/app/const/enums/question_type.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/component/notification/delete_confirmation.dart'; import 'package:quiz_app/component/notification/pop_up_confirmation.dart'; +import 'package:quiz_app/core/utils/custom_floating_loading.dart'; +import 'package:quiz_app/core/utils/logger.dart'; +import 'package:quiz_app/data/models/base/base_model.dart'; import 'package:quiz_app/data/models/quiz/quiestion_data_model.dart'; +import 'package:quiz_app/data/services/quiz_service.dart'; class QuizCreationController extends GetxController { + final QuizService _quizService; + + QuizCreationController(this._quizService); + + final TextEditingController inputSentenceTC = TextEditingController(); final TextEditingController questionTC = TextEditingController(); final TextEditingController answerTC = TextEditingController(); final List optionTCList = List.generate(4, (_) => TextEditingController()); @@ -213,4 +222,56 @@ class QuizCreationController extends GetxController { selectedQuizIndex.value -= 1; } } + + void generateQuiz() async { + CustomFloatingLoading.showLoadingDialog(Get.context!); + + try { + BaseResponseModel> response = await _quizService.createQuizAuto(inputSentenceTC.text); + + if (response.data != null) { + final previousLength = quizData.length; + + if (previousLength == 1) quizData.removeAt(0); + + for (final i in response.data!) { + QuestionType type = QuestionType.fillTheBlank; + + if (i.answer.toString().toLowerCase() == 'true' || i.answer.toString().toLowerCase() == 'false') { + type = QuestionType.trueOrFalse; + } + + quizData.add(QuestionData( + index: quizData.length + 1, + question: i.qustion, + answer: i.answer, + type: type, + )); + } + + if (response.data!.isNotEmpty) { + selectedQuizIndex.value = previousLength; + final data = quizData[selectedQuizIndex.value]; + questionTC.text = data.question ?? ""; + answerTC.text = data.answer ?? ""; + currentDuration.value = data.duration; + currentQuestionType.value = data.type ?? QuestionType.fillTheBlank; + return; + } + } + } catch (e) { + logC.e("Error while generating quiz: $e"); + } finally { + CustomFloatingLoading.hideLoadingDialog(Get.context!); + isGenerate.value = false; + + if (quizData.isNotEmpty && selectedQuizIndex.value == 0) { + final data = quizData[0]; + questionTC.text = data.question ?? ""; + answerTC.text = data.answer ?? ""; + currentDuration.value = data.duration; + currentQuestionType.value = data.type ?? QuestionType.fillTheBlank; + } + } + } } diff --git a/lib/feature/quiz_creation/view/component/custom_question_component.dart b/lib/feature/quiz_creation/view/component/custom_question_component.dart index c543240..17fe6da 100644 --- a/lib/feature/quiz_creation/view/component/custom_question_component.dart +++ b/lib/feature/quiz_creation/view/component/custom_question_component.dart @@ -1,7 +1,9 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; import 'package:quiz_app/app/const/enums/question_type.dart'; +import 'package:quiz_app/component/global_button.dart'; import 'package:quiz_app/feature/quiz_creation/controller/quiz_creation_controller.dart'; import 'package:quiz_app/feature/quiz_creation/view/component/fill_the_blank_component.dart'; import 'package:quiz_app/feature/quiz_creation/view/component/option_question_component.dart'; @@ -30,6 +32,11 @@ class CustomQuestionComponent extends GetView { _questionTypeValue(), const SizedBox(height: 20), _buildDurationDropdown(), + const SizedBox(height: 30), + GlobalButton( + text: context.tr('save_all'), + onPressed: controller.onDone, + ) ], ); } diff --git a/lib/feature/quiz_creation/view/component/generate_component.dart b/lib/feature/quiz_creation/view/component/generate_component.dart index 70ef06a..04b8b2f 100644 --- a/lib/feature/quiz_creation/view/component/generate_component.dart +++ b/lib/feature/quiz_creation/view/component/generate_component.dart @@ -1,17 +1,19 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/component/global_button.dart'; +import 'package:quiz_app/component/global_text_field.dart'; import 'package:quiz_app/feature/quiz_creation/controller/quiz_creation_controller.dart'; class GenerateComponent extends GetView { const GenerateComponent({super.key}); - @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( - "Unggah file materi kamu (PDF atau Word) untuk membuat soal otomatis.", + "Masukkan paragraf untuk dijadikan soal", style: TextStyle( fontSize: 14, color: Color(0xFF6B778C), @@ -19,41 +21,16 @@ class GenerateComponent extends GetView { ), ), const SizedBox(height: 16), - GestureDetector( - onTap: () {}, - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 30), - decoration: BoxDecoration( - color: const Color(0xFFF0F2F5), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Colors.grey.shade300), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: const [ - Icon(Icons.insert_drive_file, size: 50, color: Color(0xFF6B778C)), - SizedBox(height: 10), - Text( - "Upload PDF atau Word", - style: TextStyle( - fontSize: 16, - color: Color(0xFF6B778C), - fontWeight: FontWeight.w600, - ), - ), - SizedBox(height: 8), - Text( - "Max 10 MB", - style: TextStyle( - fontSize: 12, - color: Color(0xFF9FA8B2), - ), - ), - ], - ), - ), + GlobalTextField( + hintText: "Tulis kalimat atau paragraf panjang, dan kami akan mengubahnya menjadi soal secara otomatis", + controller: controller.inputSentenceTC, + limitTextLine: 15, ), + const SizedBox(height: 16), + GlobalButton( + text: context.tr('auto_generate_quiz'), + onPressed: controller.generateQuiz, + ) ], ); } diff --git a/lib/feature/quiz_creation/view/quiz_creation_view.dart b/lib/feature/quiz_creation/view/quiz_creation_view.dart index 53dcefd..114db26 100644 --- a/lib/feature/quiz_creation/view/quiz_creation_view.dart +++ b/lib/feature/quiz_creation/view/quiz_creation_view.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; -import 'package:quiz_app/component/global_button.dart'; import 'package:quiz_app/feature/quiz_creation/controller/quiz_creation_controller.dart'; import 'package:quiz_app/feature/quiz_creation/view/component/custom_question_component.dart'; import 'package:quiz_app/feature/quiz_creation/view/component/generate_component.dart'; @@ -40,11 +39,6 @@ class QuizCreationView extends GetView { _buildModeSelector(context), const SizedBox(height: 20), Obx(() => controller.isGenerate.value ? const GenerateComponent() : const CustomQuestionComponent()), - const SizedBox(height: 30), - GlobalButton( - text: context.tr('save_all'), - onPressed: controller.onDone, - ) ], ), ), From 60091b8031a4a7f0fe291b277ed7647795bc62b0 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 18 May 2025 15:36:39 +0700 Subject: [PATCH 066/104] feat: done create update profile interface --- lib/app/routes/app_pages.dart | 7 ++ lib/app/routes/app_routes.dart | 2 + lib/component/global_dropdown_field.dart | 37 +++++++++++ .../binding/update_profile_binding.dart | 9 +++ .../controller/profile_controller.dart | 2 +- .../controller/update_profile_controller.dart | 20 ++++++ .../profile/view/update_profile_view.dart | 66 +++++++++++++++++++ 7 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 lib/component/global_dropdown_field.dart create mode 100644 lib/feature/profile/binding/update_profile_binding.dart create mode 100644 lib/feature/profile/controller/update_profile_controller.dart create mode 100644 lib/feature/profile/view/update_profile_view.dart diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index 692f6e5..773afc9 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -21,6 +21,8 @@ import 'package:quiz_app/feature/navigation/views/navbar_view.dart'; import 'package:quiz_app/feature/play_quiz_multiplayer/binding/play_quiz_multiplayer_binding.dart'; import 'package:quiz_app/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart'; import 'package:quiz_app/feature/profile/binding/profile_binding.dart'; +import 'package:quiz_app/feature/profile/binding/update_profile_binding.dart'; +import 'package:quiz_app/feature/profile/view/update_profile_view.dart'; import 'package:quiz_app/feature/quiz_creation/binding/quiz_creation_binding.dart'; import 'package:quiz_app/feature/quiz_creation/view/quiz_creation_view.dart'; import 'package:quiz_app/feature/quiz_play/binding/quiz_play_binding.dart'; @@ -135,6 +137,11 @@ class AppPages { page: () => PlayQuizMultiplayerView(), binding: PlayQuizMultiplayerBinding(), ), + GetPage( + name: AppRoutes.updateProfilePage, + page: () => UpdateProfilePage(), + binding: UpdateProfileBinding(), + ), // GetPage( // name: AppRoutes.monitorResultMPLPage, // page: () => AdminResultPage(), diff --git a/lib/app/routes/app_routes.dart b/lib/app/routes/app_routes.dart index 3410b5d..49ebba5 100644 --- a/lib/app/routes/app_routes.dart +++ b/lib/app/routes/app_routes.dart @@ -25,4 +25,6 @@ abstract class AppRoutes { static const playQuizMPLPage = "/room/quiz/play"; static const monitorQuizMPLPage = "/room/quiz/monitor"; static const monitorResultMPLPage = "/room/quiz/monitor/result"; + + static const updateProfilePage = "/profile/update"; } diff --git a/lib/component/global_dropdown_field.dart b/lib/component/global_dropdown_field.dart new file mode 100644 index 0000000..9802887 --- /dev/null +++ b/lib/component/global_dropdown_field.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +class GlobalDropdownField extends StatelessWidget { + final T value; + final List> items; + final ValueChanged onChanged; + + const GlobalDropdownField({ + super.key, + required this.value, + required this.items, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + decoration: BoxDecoration( + color: const Color.fromARGB(255, 234, 234, 235), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: const Color(0xFF0052CC), + width: 1.5, + ), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + isExpanded: true, + onChanged: onChanged, + items: items, + ), + ), + ); + } +} diff --git a/lib/feature/profile/binding/update_profile_binding.dart b/lib/feature/profile/binding/update_profile_binding.dart new file mode 100644 index 0000000..505a59d --- /dev/null +++ b/lib/feature/profile/binding/update_profile_binding.dart @@ -0,0 +1,9 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/feature/profile/controller/update_profile_controller.dart'; + +class UpdateProfileBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => UpdateProfileController()); + } +} diff --git a/lib/feature/profile/controller/profile_controller.dart b/lib/feature/profile/controller/profile_controller.dart index 3fe93d7..7e7d088 100644 --- a/lib/feature/profile/controller/profile_controller.dart +++ b/lib/feature/profile/controller/profile_controller.dart @@ -39,7 +39,7 @@ class ProfileController extends GetxController { } void editProfile() { - logC.i("Edit profile pressed"); + Get.toNamed(AppRoutes.updateProfilePage); } void changeLanguage(BuildContext context, String languageCode, String countryCode) async { diff --git a/lib/feature/profile/controller/update_profile_controller.dart b/lib/feature/profile/controller/update_profile_controller.dart new file mode 100644 index 0000000..2684083 --- /dev/null +++ b/lib/feature/profile/controller/update_profile_controller.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +import 'package:get/get.dart'; + +class UpdateProfileController extends GetxController { + final nameController = TextEditingController(); + final phoneController = TextEditingController(); + final birthDateController = TextEditingController(); + + var selectedLocale = 'en-US'.obs; + final Map localeMap = { + 'English': 'en-US', + 'Indonesian': 'id-ID', + 'French': 'fr-FR', + 'Spanish': 'es-ES', + }; + void saveProfile() { + Get.snackbar('Success', 'Profile updated successfully'); + } +} diff --git a/lib/feature/profile/view/update_profile_view.dart b/lib/feature/profile/view/update_profile_view.dart new file mode 100644 index 0000000..3bb4404 --- /dev/null +++ b/lib/feature/profile/view/update_profile_view.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/component/global_button.dart'; +import 'package:quiz_app/component/global_dropdown_field.dart'; +import 'package:quiz_app/component/global_text_field.dart'; +import 'package:quiz_app/component/label_text_field.dart'; +import 'package:quiz_app/feature/profile/controller/update_profile_controller.dart'; + +class UpdateProfilePage extends GetView { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Update Profile'), + centerTitle: true, + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: ListView( + children: [ + LabelTextField(label: "Name"), + GlobalTextField(controller: controller.nameController), + SizedBox(height: 16), + LabelTextField(label: "Phone"), + GlobalTextField( + controller: controller.phoneController, + hintText: 'Enter your phone number', + ), + SizedBox(height: 16), + LabelTextField(label: "Birth Date"), + GlobalTextField( + controller: controller.birthDateController, + hintText: 'Enter your birth date', + ), + SizedBox(height: 16), + LabelTextField(label: "Locale"), + Obx(() => GlobalDropdownField( + value: controller.selectedLocale.value, + items: controller.localeMap.entries.map>((entry) { + return DropdownMenuItem( + value: entry.value, + child: Text(entry.key), // Display country name + ); + }).toList(), + onChanged: (String? newValue) { + if (newValue != null) { + controller.selectedLocale.value = newValue; + final parts = newValue.split('-'); + if (parts.length == 2) { + Get.updateLocale(Locale(parts[0], parts[1])); + } else { + Get.updateLocale(Locale(newValue)); + } + } + }, + )), + SizedBox(height: 32), + Center( + child: GlobalButton(text: "save_changes", onPressed: controller.saveProfile), + ), + ], + ), + ), + ); + } +} From a233c844caf1e9b53f35ca01417a7afa9d1aa729 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 18 May 2025 15:52:33 +0700 Subject: [PATCH 067/104] feat: adjust the join room interface --- assets/translations/en-US.json | 5 +- assets/translations/id-ID.json | 6 +- assets/translations/ms-MY.json | 5 +- .../join_room/view/join_room_view.dart | 242 +++++++++++++++--- 4 files changed, 213 insertions(+), 45 deletions(-) diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index 9eb7d68..06591f6 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -84,5 +84,8 @@ "select_language": "Select Language", "change_language": "Change Language", - "auto_generate_quiz": "Auto Generate Quiz" + "auto_generate_quiz": "Auto Generate Quiz", + "ready_to_compete": "Ready to Compete?", + "enter_code_to_join": "Enter the quiz code and show your skills!", + "join_quiz_now": "Join Quiz Now" } diff --git a/assets/translations/id-ID.json b/assets/translations/id-ID.json index cef3637..8e02cf2 100644 --- a/assets/translations/id-ID.json +++ b/assets/translations/id-ID.json @@ -83,5 +83,9 @@ "select_language": "Pilih Bahasa", "change_language": "Ganti Bahasa", - "auto_generate_quiz": "Buat Kuis Otomatis" + "auto_generate_quiz": "Buat Kuis Otomatis", + + "ready_to_compete": "Siap untuk Bertanding?", + "enter_code_to_join": "Masukkan kode kuis dan tunjukkan kemampuanmu!", + "join_quiz_now": "Gabung Kuis Sekarang" } diff --git a/assets/translations/ms-MY.json b/assets/translations/ms-MY.json index 9cbc487..32eb62f 100644 --- a/assets/translations/ms-MY.json +++ b/assets/translations/ms-MY.json @@ -81,5 +81,8 @@ "make_quiz_public": "Jadikan Kuiz Umum", "save_quiz": "Simpan Kuiz", - "auto_generate_quiz": "Jana Kuiz Automatik" + "auto_generate_quiz": "Jana Kuiz Automatik", + "ready_to_compete": "Bersedia untuk Bertanding?", + "enter_code_to_join": "Masukkan kod kuiz dan tunjukkan kemahiran anda!", + "join_quiz_now": "Sertai Kuiz Sekarang" } diff --git a/lib/feature/join_room/view/join_room_view.dart b/lib/feature/join_room/view/join_room_view.dart index 45103a4..ed3f197 100644 --- a/lib/feature/join_room/view/join_room_view.dart +++ b/lib/feature/join_room/view/join_room_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:lucide_icons/lucide_icons.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; import 'package:quiz_app/component/global_button.dart'; import 'package:quiz_app/component/global_text_field.dart'; @@ -10,59 +11,216 @@ class JoinRoomView extends GetView { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: AppColors.background, + backgroundColor: Colors.white, + // Menggunakan extendBodyBehindAppBar untuk efek lebih menarik + extendBodyBehindAppBar: true, appBar: AppBar( - backgroundColor: AppColors.background, + backgroundColor: Colors.transparent, elevation: 0, leading: IconButton( - icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black87), + icon: const Icon(LucideIcons.arrowLeft, color: Colors.black87), onPressed: () => Get.back(), ), ), - body: SafeArea( - child: Center( + body: Container( + // Background putih bersih + color: Colors.white, + child: SafeArea( child: SingleChildScrollView( - padding: const EdgeInsets.all(24.0), - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.06), - blurRadius: 14, - offset: const Offset(0, 6), - ), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.tr("enter_room_code"), - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w700, - color: Colors.black87, + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 20), + + // Animated Trophy Icon with Hero animation + TweenAnimationBuilder( + duration: const Duration(seconds: 1), + tween: Tween(begin: 0.0, end: 1.0), + builder: (context, value, child) { + return Transform.scale( + scale: value, + child: child, + ); + }, + child: Container( + padding: EdgeInsets.all(22), + decoration: BoxDecoration( + color: AppColors.primaryBlue.withOpacity(0.05), + shape: BoxShape.circle, + border: Border.all( + color: AppColors.primaryBlue.withOpacity(0.15), + width: 2, + ), + ), + child: Icon( + LucideIcons.trophy, + size: 70, + color: AppColors.primaryBlue, ), ), - const SizedBox(height: 16), - GlobalTextField( - controller: controller.codeController, - hintText: context.tr("room_code_hint"), - textInputType: TextInputType.text, - forceUpperCase: true, + ), + + const SizedBox(height: 30), + + // Animated Title + TweenAnimationBuilder( + duration: const Duration(milliseconds: 800), + tween: Tween(begin: 0.0, end: 1.0), + builder: (context, value, child) { + return Opacity( + opacity: value, + child: Transform.translate( + offset: Offset(0, 20 * (1 - value)), + child: child, + ), + ); + }, + child: Text( + context.tr("ready_to_compete"), + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + textAlign: TextAlign.center, ), - const SizedBox(height: 30), - GlobalButton( - text: context.tr("join_now"), - onPressed: controller.joinRoom, + ), + + const SizedBox(height: 15), + + // Animated Subtitle + TweenAnimationBuilder( + duration: const Duration(milliseconds: 800), + tween: Tween(begin: 0.0, end: 1.0), + builder: (context, value, child) { + return Opacity( + opacity: value, + child: Transform.translate( + offset: Offset(0, 20 * (1 - value)), + child: child, + ), + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text( + context.tr("enter_code_to_join"), + style: const TextStyle( + fontSize: 16, + color: Colors.black54, + height: 1.4, + ), + textAlign: TextAlign.center, + ), ), - ], - ), + ), + + const SizedBox(height: 40), + + // Animated Card + TweenAnimationBuilder( + duration: const Duration(milliseconds: 1000), + // delay: const Duration(milliseconds: 400), + tween: Tween(begin: 0.0, end: 1.0), + builder: (context, value, child) { + return Opacity( + opacity: value, + child: Transform.translate( + offset: Offset(0, 30 * (1 - value)), + child: child, + ), + ); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 30), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.08), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + LucideIcons.keySquare, + color: AppColors.primaryBlue, + size: 24, + ), + const SizedBox(width: 12), + Text( + context.tr("enter_room_code"), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + ], + ), + const SizedBox(height: 25), + + // Custom text field with better styling + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.grey.shade200, + width: 1, + ), + ), + child: GlobalTextField( + controller: controller.codeController, + hintText: context.tr("room_code_hint"), + textInputType: TextInputType.text, + forceUpperCase: true, + ), + ), + + const SizedBox(height: 30), + + // Button with gradient + GlobalButton( + text: context.tr("join_quiz_now"), + onPressed: controller.joinRoom, + ), + ], + ), + ), + ), + + const SizedBox(height: 30), + + // TweenAnimationBuilder( + // duration: const Duration(milliseconds: 800), + // tween: Tween(begin: 0.0, end: 1.0), + // builder: (context, value, child) { + // return Opacity( + // opacity: value, + // child: child, + // ); + // }, + // child: TextButton( + // onPressed: () {}, + // child: Text( + // context.tr("create_new_room"), + // style: TextStyle( + // color: AppColors.primaryBlue, + // fontWeight: FontWeight.w500, + // ), + // ), + // ), + // ), + ], ), ), ), From 175f4e6668e69b6b665c49c4282bab70b631b578 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 18 May 2025 15:55:09 +0700 Subject: [PATCH 068/104] feat: clean up environtment --- .../widget/quiz_item_wa_component.dart | 6 +++--- .../view/admin_result_page.dart | 10 +++++----- .../view/detail_participant_result_page.dart | 4 ++-- .../controller/join_room_controller.dart | 2 +- .../join_room/view/join_room_view.dart | 19 +++++-------------- lib/feature/library/view/library_view.dart | 2 +- .../monitor_quiz/view/monitor_quiz_view.dart | 8 ++++---- .../room_maker/view/room_maker_view.dart | 2 +- 8 files changed, 22 insertions(+), 31 deletions(-) diff --git a/lib/component/widget/quiz_item_wa_component.dart b/lib/component/widget/quiz_item_wa_component.dart index a9b4b8e..ea7e79a 100644 --- a/lib/component/widget/quiz_item_wa_component.dart +++ b/lib/component/widget/quiz_item_wa_component.dart @@ -39,7 +39,7 @@ class QuizItemWAComponent extends StatelessWidget { borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.04), + color: Colors.black.withValues(alpha: 0.04), blurRadius: 6, offset: const Offset(0, 2), ), @@ -78,11 +78,11 @@ class QuizItemWAComponent extends StatelessWidget { Color iconColor = AppColors.shadowPrimary; if (isCorrectAnswer) { - backgroundColor = AppColors.primaryBlue.withOpacity(0.15); + backgroundColor = AppColors.primaryBlue.withValues(alpha: 0.15); icon = LucideIcons.checkCircle2; iconColor = AppColors.primaryBlue; } else if (isUserWrongAnswer) { - backgroundColor = Colors.red.withOpacity(0.15); + backgroundColor = Colors.red.withValues(alpha: 0.15); icon = LucideIcons.xCircle; iconColor = Colors.red; } diff --git a/lib/feature/admin_result_page/view/admin_result_page.dart b/lib/feature/admin_result_page/view/admin_result_page.dart index 1232724..c09a5f9 100644 --- a/lib/feature/admin_result_page/view/admin_result_page.dart +++ b/lib/feature/admin_result_page/view/admin_result_page.dart @@ -65,10 +65,10 @@ // return Card( // elevation: 2, // color: Colors.white, -// shadowColor: AppColors.shadowPrimary.withOpacity(0.2), +// shadowColor: AppColors.shadowPrimary.withValues(alpha:0.2), // shape: RoundedRectangleBorder( // borderRadius: BorderRadius.circular(16), -// side: BorderSide(color: AppColors.accentBlue.withOpacity(0.2)), +// side: BorderSide(color: AppColors.accentBlue.withValues(alpha:0.2)), // ), // child: Padding( // padding: const EdgeInsets.all(20), @@ -164,10 +164,10 @@ // child: Card( // elevation: 2, // color: Colors.white, -// shadowColor: AppColors.shadowPrimary.withOpacity(0.2), +// shadowColor: AppColors.shadowPrimary.withValues(alpha:0.2), // shape: RoundedRectangleBorder( // borderRadius: BorderRadius.circular(16), -// side: BorderSide(color: AppColors.accentBlue.withOpacity(0.2)), +// side: BorderSide(color: AppColors.accentBlue.withValues(alpha:0.2)), // ), // child: Padding( // padding: const EdgeInsets.all(20), @@ -216,7 +216,7 @@ // height: 60, // decoration: BoxDecoration( // shape: BoxShape.circle, -// color: _getScoreColor(participant.scorePercent).withOpacity(0.1), +// color: _getScoreColor(participant.scorePercent).withValues(alpha:0.1), // border: Border.all( // color: _getScoreColor(participant.scorePercent), // width: 2, diff --git a/lib/feature/admin_result_page/view/detail_participant_result_page.dart b/lib/feature/admin_result_page/view/detail_participant_result_page.dart index 967011f..f121b27 100644 --- a/lib/feature/admin_result_page/view/detail_participant_result_page.dart +++ b/lib/feature/admin_result_page/view/detail_participant_result_page.dart @@ -98,7 +98,7 @@ // Container( // padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), // decoration: BoxDecoration( -// color: _getScoreColor(participant.scorePercent).withOpacity(0.1), +// color: _getScoreColor(participant.scorePercent).withValues(alpha:0.1), // borderRadius: BorderRadius.circular(16), // border: Border.all( // color: _getScoreColor(participant.scorePercent), @@ -145,7 +145,7 @@ // height: 28, // decoration: BoxDecoration( // shape: BoxShape.circle, -// color: answer.isCorrect ? AppColors.primaryBlue.withOpacity(0.1) : AppColors.scorePoor.withOpacity(0.1), +// color: answer.isCorrect ? AppColors.primaryBlue.withValues(alpha:0.1) : AppColors.scorePoor.withValues(alpha:0.1), // ), // child: Center( // child: Text( diff --git a/lib/feature/join_room/controller/join_room_controller.dart b/lib/feature/join_room/controller/join_room_controller.dart index 5577158..4306f53 100644 --- a/lib/feature/join_room/controller/join_room_controller.dart +++ b/lib/feature/join_room/controller/join_room_controller.dart @@ -24,7 +24,7 @@ class JoinRoomController extends GetxController { Get.snackbar( "Error", "Kode room dan nama harus diisi", - backgroundColor: Get.theme.colorScheme.error.withOpacity(0.9), + backgroundColor: Get.theme.colorScheme.error.withValues(alpha: 0.9), colorText: Colors.white, ); return; diff --git a/lib/feature/join_room/view/join_room_view.dart b/lib/feature/join_room/view/join_room_view.dart index ed3f197..45215a1 100644 --- a/lib/feature/join_room/view/join_room_view.dart +++ b/lib/feature/join_room/view/join_room_view.dart @@ -8,11 +8,12 @@ import 'package:quiz_app/component/global_text_field.dart'; import 'package:quiz_app/feature/join_room/controller/join_room_controller.dart'; class JoinRoomView extends GetView { + const JoinRoomView({super.key}); + @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, - // Menggunakan extendBodyBehindAppBar untuk efek lebih menarik extendBodyBehindAppBar: true, appBar: AppBar( backgroundColor: Colors.transparent, @@ -23,7 +24,6 @@ class JoinRoomView extends GetView { ), ), body: Container( - // Background putih bersih color: Colors.white, child: SafeArea( child: SingleChildScrollView( @@ -33,7 +33,6 @@ class JoinRoomView extends GetView { children: [ const SizedBox(height: 20), - // Animated Trophy Icon with Hero animation TweenAnimationBuilder( duration: const Duration(seconds: 1), tween: Tween(begin: 0.0, end: 1.0), @@ -46,10 +45,10 @@ class JoinRoomView extends GetView { child: Container( padding: EdgeInsets.all(22), decoration: BoxDecoration( - color: AppColors.primaryBlue.withOpacity(0.05), + color: AppColors.primaryBlue.withValues(alpha: 0.05), shape: BoxShape.circle, border: Border.all( - color: AppColors.primaryBlue.withOpacity(0.15), + color: AppColors.primaryBlue.withValues(alpha: 0.15), width: 2, ), ), @@ -63,7 +62,6 @@ class JoinRoomView extends GetView { const SizedBox(height: 30), - // Animated Title TweenAnimationBuilder( duration: const Duration(milliseconds: 800), tween: Tween(begin: 0.0, end: 1.0), @@ -118,10 +116,8 @@ class JoinRoomView extends GetView { const SizedBox(height: 40), - // Animated Card TweenAnimationBuilder( duration: const Duration(milliseconds: 1000), - // delay: const Duration(milliseconds: 400), tween: Tween(begin: 0.0, end: 1.0), builder: (context, value, child) { return Opacity( @@ -139,7 +135,7 @@ class JoinRoomView extends GetView { borderRadius: BorderRadius.circular(24), boxShadow: [ BoxShadow( - color: Colors.grey.withOpacity(0.08), + color: Colors.grey.withValues(alpha: 0.08), blurRadius: 15, offset: const Offset(0, 5), ), @@ -167,8 +163,6 @@ class JoinRoomView extends GetView { ], ), const SizedBox(height: 25), - - // Custom text field with better styling Container( decoration: BoxDecoration( color: Colors.white, @@ -185,10 +179,7 @@ class JoinRoomView extends GetView { forceUpperCase: true, ), ), - const SizedBox(height: 30), - - // Button with gradient GlobalButton( text: context.tr("join_quiz_now"), onPressed: controller.joinRoom, diff --git a/lib/feature/library/view/library_view.dart b/lib/feature/library/view/library_view.dart index a583c9b..aaeeeb5 100644 --- a/lib/feature/library/view/library_view.dart +++ b/lib/feature/library/view/library_view.dart @@ -78,7 +78,7 @@ class LibraryView extends GetView { borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.05), + color: Colors.black.withValues(alpha: 0.05), blurRadius: 6, offset: const Offset(0, 2), ), diff --git a/lib/feature/monitor_quiz/view/monitor_quiz_view.dart b/lib/feature/monitor_quiz/view/monitor_quiz_view.dart index 32f5570..57c7788 100644 --- a/lib/feature/monitor_quiz/view/monitor_quiz_view.dart +++ b/lib/feature/monitor_quiz/view/monitor_quiz_view.dart @@ -139,10 +139,10 @@ class MonitorQuizView extends GetView { return Card( elevation: 2, color: Colors.white, - shadowColor: AppColors.shadowPrimary.withOpacity(0.2), + shadowColor: AppColors.shadowPrimary.withValues(alpha: 0.2), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - side: BorderSide(color: AppColors.accentBlue.withOpacity(0.2)), + side: BorderSide(color: AppColors.accentBlue.withValues(alpha: 0.2)), ), child: Padding( padding: const EdgeInsets.all(20), @@ -246,9 +246,9 @@ class MonitorQuizView extends GetView { width: 130, padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), decoration: BoxDecoration( - color: color.withOpacity(0.08), + color: color.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(8), - border: Border.all(color: color.withOpacity(0.2), width: 1), + border: Border.all(color: color.withValues(alpha: 0.2), width: 1), ), child: Row( children: [ diff --git a/lib/feature/room_maker/view/room_maker_view.dart b/lib/feature/room_maker/view/room_maker_view.dart index cf0ba5d..bf79d92 100644 --- a/lib/feature/room_maker/view/room_maker_view.dart +++ b/lib/feature/room_maker/view/room_maker_view.dart @@ -77,7 +77,7 @@ class RoomMakerView extends GetView { borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.05), + color: Colors.black.withValues(alpha: 0.05), blurRadius: 8, offset: Offset(0, 4), ), From 1c6ce0d023dcf14ce8e15c14e66c55416f793429 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 18 May 2025 21:20:20 +0700 Subject: [PATCH 069/104] feat: done working on the profile update --- lib/core/endpoint/api_endpoint.dart | 3 + lib/data/entity/user/user_entity.dart | 15 ++- lib/data/models/user/user_full_model.dart | 55 +++++++++++ lib/data/services/user_service.dart | 57 +++++++++++ .../binding/update_profile_binding.dart | 10 +- .../controller/update_profile_controller.dart | 94 ++++++++++++++++++- 6 files changed, 226 insertions(+), 8 deletions(-) create mode 100644 lib/data/models/user/user_full_model.dart create mode 100644 lib/data/services/user_service.dart diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart index cab3205..6da5d84 100644 --- a/lib/core/endpoint/api_endpoint.dart +++ b/lib/core/endpoint/api_endpoint.dart @@ -22,4 +22,7 @@ class APIEndpoint { static const String subject = "/subject"; static const String session = "/session"; + + static const String userData = "/user"; + static const String userUpdate = "/user/update"; } diff --git a/lib/data/entity/user/user_entity.dart b/lib/data/entity/user/user_entity.dart index 7528448..733ad60 100644 --- a/lib/data/entity/user/user_entity.dart +++ b/lib/data/entity/user/user_entity.dart @@ -3,23 +3,29 @@ class UserEntity { final String name; final String email; final String? picUrl; + final String? birthDate; final String? locale; + final String? phone; -UserEntity({ + UserEntity({ required this.id, required this.name, required this.email, this.picUrl, + this.birthDate, this.locale, + this.phone, }); factory UserEntity.fromJson(Map json) { return UserEntity( - id: json['id'], - name: json['name'], - email: json['email'], + id: json['id'] ?? '', + name: json['name'] ?? '', + email: json['email'] ?? '', picUrl: json['pic_url'], + birthDate: json['birth_date'], locale: json['locale'], + phone: json['phone'], ); } @@ -29,6 +35,7 @@ UserEntity({ 'name': name, 'email': email, 'pic_url': picUrl, + 'birth_date': birthDate, 'locale': locale, }; } diff --git a/lib/data/models/user/user_full_model.dart b/lib/data/models/user/user_full_model.dart new file mode 100644 index 0000000..46f6ad6 --- /dev/null +++ b/lib/data/models/user/user_full_model.dart @@ -0,0 +1,55 @@ +class UserFullModel { + final String id; + final String googleId; + final String email; + final String name; + final String birthDate; + final String picUrl; + final String phone; + final String locale; + final String createdAt; + final String updatedAt; + + UserFullModel({ + required this.id, + required this.googleId, + required this.email, + required this.name, + required this.birthDate, + required this.picUrl, + required this.phone, + required this.locale, + required this.createdAt, + required this.updatedAt, + }); + + factory UserFullModel.fromJson(Map json) { + return UserFullModel( + id: json['id'] ?? '', + googleId: json['google_id'] ?? '', + email: json['email'] ?? '', + name: json['name'] ?? '', + birthDate: json['birth_date'] ?? '', + picUrl: json['pic_url'] ?? '', + phone: json['phone'] ?? '', + locale: json['locale'] ?? '', + createdAt: json['created_at'] ?? '', + updatedAt: json['updated_at'] ?? '', + ); + } + + Map toJson() { + return { + 'id': id, + 'google_id': googleId, + 'email': email, + 'name': name, + 'birth_date': birthDate, + 'pic_url': picUrl, + 'phone': phone, + 'locale': locale, + 'created_at': createdAt, + 'updated_at': updatedAt, + }; + } +} diff --git a/lib/data/services/user_service.dart b/lib/data/services/user_service.dart new file mode 100644 index 0000000..6c5e9e3 --- /dev/null +++ b/lib/data/services/user_service.dart @@ -0,0 +1,57 @@ +import 'package:dio/dio.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/core/endpoint/api_endpoint.dart'; +import 'package:quiz_app/core/utils/logger.dart'; +import 'package:quiz_app/data/models/base/base_model.dart'; +import 'package:quiz_app/data/models/user/user_full_model.dart'; +import 'package:quiz_app/data/providers/dio_client.dart'; + +class UserService extends GetxService { + late final Dio _dio; + + @override + void onInit() { + _dio = Get.find().dio; + super.onInit(); + } + + Future updateProfileData(String id, String name, {String? birthDate, String? locale, String? phone}) async { + try { + final response = await _dio.post(APIEndpoint.userUpdate, data: { + "id": id, + "name": name, + "birth_date": birthDate, + "locale": locale, + "phone": phone, + }); + + if (response.statusCode == 200) { + return true; + } else { + return false; + } + } catch (e) { + logC.e("update profile error: $e"); + return false; + } + } + + Future?> getUserData(String id) async { + try { + final response = await _dio.get("${APIEndpoint.userData}/$id"); + + if (response.statusCode == 200) { + final parsedResponse = BaseResponseModel.fromJson( + response.data, + (data) => UserFullModel.fromJson(data), + ); + return parsedResponse; + } else { + return null; + } + } catch (e) { + logC.e("get user data error: $e"); + return null; + } + } +} diff --git a/lib/feature/profile/binding/update_profile_binding.dart b/lib/feature/profile/binding/update_profile_binding.dart index 505a59d..db18f20 100644 --- a/lib/feature/profile/binding/update_profile_binding.dart +++ b/lib/feature/profile/binding/update_profile_binding.dart @@ -1,9 +1,17 @@ import 'package:get/get.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/services/user_service.dart'; +import 'package:quiz_app/data/services/user_storage_service.dart'; import 'package:quiz_app/feature/profile/controller/update_profile_controller.dart'; class UpdateProfileBinding extends Bindings { @override void dependencies() { - Get.lazyPut(() => UpdateProfileController()); + Get.lazyPut(() => UserService()); + Get.lazyPut(() => UpdateProfileController( + Get.find(), + Get.find(), + Get.find(), + )); } } diff --git a/lib/feature/profile/controller/update_profile_controller.dart b/lib/feature/profile/controller/update_profile_controller.dart index 2684083..8f4ce8b 100644 --- a/lib/feature/profile/controller/update_profile_controller.dart +++ b/lib/feature/profile/controller/update_profile_controller.dart @@ -1,20 +1,108 @@ import 'package:flutter/material.dart'; - import 'package:get/get.dart'; +import 'package:quiz_app/core/utils/custom_floating_loading.dart'; +import 'package:quiz_app/core/utils/custom_notification.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/entity/user/user_entity.dart'; +import 'package:quiz_app/data/services/user_service.dart'; +import 'package:quiz_app/data/services/user_storage_service.dart'; class UpdateProfileController extends GetxController { + final UserController _userController; + final UserStorageService _userStorageService; + final UserService _userService; + + UpdateProfileController( + this._userService, + this._userController, + this._userStorageService, + ); + final nameController = TextEditingController(); final phoneController = TextEditingController(); final birthDateController = TextEditingController(); var selectedLocale = 'en-US'.obs; + final Map localeMap = { 'English': 'en-US', 'Indonesian': 'id-ID', 'French': 'fr-FR', 'Spanish': 'es-ES', }; - void saveProfile() { - Get.snackbar('Success', 'Profile updated successfully'); + + @override + void onInit() { + final userData = _userController.userData!; + nameController.text = userData.name; + phoneController.text = userData.phone ?? ''; + birthDateController.text = userData.birthDate ?? ''; + super.onInit(); + } + + bool _validateInputs() { + final name = nameController.text.trim(); + final phone = phoneController.text.trim(); + final birthDate = birthDateController.text.trim(); + print(birthDate); + + if (name.isEmpty || phone.isEmpty || birthDate.isEmpty) { + Get.snackbar('Validation Error', 'All fields must be filled.', snackPosition: SnackPosition.TOP); + return false; + } + + if (!_isValidDateFormat(birthDate)) { + Get.snackbar('Validation Error', 'birth date must valid.', snackPosition: SnackPosition.TOP); + return false; + } + return true; + } + + Future saveProfile() async { + if (!_validateInputs()) return; + + CustomFloatingLoading.showLoadingDialog(Get.context!); + + final isSuccessUpdate = await _userService.updateProfileData( + _userController.userData!.id, + nameController.text.trim(), + birthDate: birthDateController.text.trim(), + phone: phoneController.text.trim(), + locale: selectedLocale.value, + ); + + if (isSuccessUpdate) { + final response = await _userService.getUserData(_userController.userData!.id); + + if (response?.data != null) { + final userNew = response!.data!; + final newUser = UserEntity( + id: userNew.id, + email: userNew.email, + name: userNew.name, + birthDate: userNew.birthDate, + locale: userNew.locale, + picUrl: userNew.picUrl, + phone: userNew.phone, + ); + + _userStorageService.saveUser(newUser); + _userController.userData = newUser; + + _userController.email.value = userNew.email; + _userController.userName.value = userNew.name; + _userController.userImage.value = userNew.picUrl; + } + } + + Get.back(); + + CustomNotification.success(title: "Success", message: "Profile updated successfully"); + CustomFloatingLoading.hideLoadingDialog(Get.context!); + } + + bool _isValidDateFormat(String date) { + final regex = RegExp(r'^([0-2][0-9]|(3)[0-1])\-((0[1-9])|(1[0-2]))\-\d{4}$'); + return regex.hasMatch(date); } } From b8c7d62c8c7e341807f5cb48bb58ee458e99a157 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 18 May 2025 22:12:23 +0700 Subject: [PATCH 070/104] fix: the quiz answering and add loading on the join quiz --- .../controller/join_room_controller.dart | 12 +++++++++-- .../join_room/view/join_room_view.dart | 21 ------------------- .../controller/play_quiz_controller.dart | 6 ++---- .../view/play_quiz_multiplayer.dart | 4 ++-- 4 files changed, 14 insertions(+), 29 deletions(-) diff --git a/lib/feature/join_room/controller/join_room_controller.dart b/lib/feature/join_room/controller/join_room_controller.dart index 4306f53..611dc7c 100644 --- a/lib/feature/join_room/controller/join_room_controller.dart +++ b/lib/feature/join_room/controller/join_room_controller.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; -import 'package:quiz_app/core/utils/logger.dart'; +import 'package:quiz_app/core/utils/custom_floating_loading.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/dto/waiting_room_dto.dart'; import 'package:quiz_app/data/models/quiz/quiz_info_model.dart'; @@ -29,11 +29,18 @@ class JoinRoomController extends GetxController { ); return; } + CustomFloatingLoading.showLoadingDialog(Get.context!); _socketService.initSocketConnection(); _socketService.joinRoom(sessionCode: code, userId: _userController.userData!.id); _socketService.errors.listen((error) { - logC.i(error); + Get.snackbar( + "not found", + "Ruangan tidak ditemukan", + backgroundColor: Get.theme.colorScheme.error.withValues(alpha: 0.9), + colorText: Colors.white, + ); + CustomFloatingLoading.hideLoadingDialog(Get.context!); }); _socketService.roomMessages.listen((data) { @@ -42,6 +49,7 @@ class JoinRoomController extends GetxController { final Map sessionInfoJson = dataPayload["session_info"]; final Map quizInfoJson = dataPayload["quiz_info"]; + CustomFloatingLoading.hideLoadingDialog(Get.context!); Get.toNamed( AppRoutes.waitRoomPage, arguments: WaitingRoomDTO( diff --git a/lib/feature/join_room/view/join_room_view.dart b/lib/feature/join_room/view/join_room_view.dart index 45215a1..3fa9f69 100644 --- a/lib/feature/join_room/view/join_room_view.dart +++ b/lib/feature/join_room/view/join_room_view.dart @@ -190,27 +190,6 @@ class JoinRoomView extends GetView { ), const SizedBox(height: 30), - - // TweenAnimationBuilder( - // duration: const Duration(milliseconds: 800), - // tween: Tween(begin: 0.0, end: 1.0), - // builder: (context, value, child) { - // return Opacity( - // opacity: value, - // child: child, - // ); - // }, - // child: TextButton( - // onPressed: () {}, - // child: Text( - // context.tr("create_new_room"), - // style: TextStyle( - // color: AppColors.primaryBlue, - // fontWeight: FontWeight.w500, - // ), - // ), - // ), - // ), ], ), ), diff --git a/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart b/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart index 1a20595..f7f7ba3 100644 --- a/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart +++ b/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart @@ -57,13 +57,11 @@ class PlayQuizMultiplayerController extends GetxController { final model = MultiplayerQuestionModel.fromJson(Map.from(data)); currentQuestion.value = model; - // Start the timer for this question _startTimer(model.duration); }); _socketService.quizDone.listen((_) { isDone.value = true; - // Cancel timer when quiz is done _cancelTimer(); }); } @@ -98,8 +96,8 @@ class PlayQuizMultiplayerController extends GetxController { _timer = null; } - void selectOptionAnswer(String option) { - selectedAnswer.value = option; + void selectOptionAnswer(int choosenIndex) { + selectedAnswer.value = choosenIndex.toString(); buttonType.value = ButtonType.primary; } diff --git a/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart b/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart index 9368abb..afc32f9 100644 --- a/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart +++ b/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart @@ -160,7 +160,7 @@ class PlayQuizMultiplayerView extends GetView { return Column( children: List.generate(options!.length, (index) { final option = options[index]; - final isSelected = controller.selectedAnswer.value == option; + final isSelected = controller.selectedAnswer.value == index.toString(); return Container( margin: const EdgeInsets.only(bottom: 12), @@ -173,7 +173,7 @@ class PlayQuizMultiplayerView extends GetView { padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), - onPressed: () => controller.selectOptionAnswer(option), + onPressed: () => controller.selectOptionAnswer(index), child: Text(option), ), ); From 871ec13c31f6c1df5e2345ed232c7d28e77fc390 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Mon, 19 May 2025 00:44:42 +0700 Subject: [PATCH 071/104] feat: done implement admin result page --- lib/app/routes/app_pages.dart | 11 +- lib/core/endpoint/api_endpoint.dart | 2 + lib/data/models/history/session_history.dart | 87 ++++ lib/data/services/history_service.dart | 17 + .../bindings/admin_result_binding.dart | 11 + .../controller/admin_result_controller.dart | 32 ++ .../view/admin_result_page.dart | 487 +++++++++--------- .../controller/monitor_quiz_controller.dart | 5 + .../controller/room_maker_controller.dart | 71 ++- .../room_maker/view/room_maker_view.dart | 26 +- 10 files changed, 468 insertions(+), 281 deletions(-) create mode 100644 lib/data/models/history/session_history.dart create mode 100644 lib/feature/admin_result_page/bindings/admin_result_binding.dart create mode 100644 lib/feature/admin_result_page/controller/admin_result_controller.dart diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index 773afc9..71c0700 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -1,5 +1,7 @@ import 'package:get/get_navigation/src/routes/get_route.dart'; import 'package:quiz_app/app/middleware/auth_middleware.dart'; +import 'package:quiz_app/feature/admin_result_page/bindings/admin_result_binding.dart'; +import 'package:quiz_app/feature/admin_result_page/view/admin_result_page.dart'; import 'package:quiz_app/feature/history/binding/detail_history_binding.dart'; import 'package:quiz_app/feature/history/binding/history_binding.dart'; import 'package:quiz_app/feature/history/view/detail_history_view.dart'; @@ -142,9 +144,10 @@ class AppPages { page: () => UpdateProfilePage(), binding: UpdateProfileBinding(), ), - // GetPage( - // name: AppRoutes.monitorResultMPLPage, - // page: () => AdminResultPage(), - // ) + GetPage( + name: AppRoutes.monitorResultMPLPage, + page: () => AdminResultPage(), + binding: AdminResultBinding(), + ) ]; } diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart index 6da5d84..d33c324 100644 --- a/lib/core/endpoint/api_endpoint.dart +++ b/lib/core/endpoint/api_endpoint.dart @@ -23,6 +23,8 @@ class APIEndpoint { static const String session = "/session"; + static const String sessionHistory = "/history/session"; + static const String userData = "/user"; static const String userUpdate = "/user/update"; } diff --git a/lib/data/models/history/session_history.dart b/lib/data/models/history/session_history.dart new file mode 100644 index 0000000..4391410 --- /dev/null +++ b/lib/data/models/history/session_history.dart @@ -0,0 +1,87 @@ +class Participant { + final String id; + final String name; + final int score; + + Participant({ + required this.id, + required this.name, + required this.score, + }); + + factory Participant.fromJson(Map json) { + return Participant( + id: json['id'], + name: json['name'], + score: json['score'], + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'score': score, + }; + } +} + +class SessionHistory { + final String id; + final String sessionCode; + final String quizId; + final String hostId; + final DateTime createdAt; + final DateTime? startedAt; + final DateTime? endedAt; + final bool isActive; + final int participantLimit; + final List participants; + final int currentQuestionIndex; + + SessionHistory({ + required this.id, + required this.sessionCode, + required this.quizId, + required this.hostId, + required this.createdAt, + this.startedAt, + this.endedAt, + required this.isActive, + required this.participantLimit, + required this.participants, + required this.currentQuestionIndex, + }); + + factory SessionHistory.fromJson(Map json) { + return SessionHistory( + id: json['id'], + sessionCode: json['session_code'], + quizId: json['quiz_id'], + hostId: json['host_id'], + createdAt: DateTime.parse(json['created_at']), + startedAt: json['started_at'] != null ? DateTime.parse(json['started_at']) : null, + endedAt: json['ended_at'] != null ? DateTime.parse(json['ended_at']) : null, + isActive: json['is_active'], + participantLimit: json['participan_limit'], // Typo di JSON, harusnya "participant_limit" + participants: (json['participants'] as List).map((p) => Participant.fromJson(p)).toList(), + currentQuestionIndex: json['current_question_index'], + ); + } + + Map toJson() { + return { + 'id': id, + 'session_code': sessionCode, + 'quiz_id': quizId, + 'host_id': hostId, + 'created_at': createdAt.toIso8601String(), + 'started_at': startedAt?.toIso8601String(), + 'ended_at': endedAt?.toIso8601String(), + 'is_active': isActive, + 'participan_limit': participantLimit, // Tetap gunakan sesuai field JSON yang ada + 'participants': participants.map((p) => p.toJson()).toList(), + 'current_question_index': currentQuestionIndex, + }; + } +} diff --git a/lib/data/services/history_service.dart b/lib/data/services/history_service.dart index ef91028..b3816d2 100644 --- a/lib/data/services/history_service.dart +++ b/lib/data/services/history_service.dart @@ -5,6 +5,7 @@ import 'package:quiz_app/core/utils/logger.dart'; import 'package:quiz_app/data/models/base/base_model.dart'; import 'package:quiz_app/data/models/history/detail_quiz_history.dart'; import 'package:quiz_app/data/models/history/quiz_history.dart'; +import 'package:quiz_app/data/models/history/session_history.dart'; import 'package:quiz_app/data/providers/dio_client.dart'; class HistoryService extends GetxService { @@ -45,4 +46,20 @@ class HistoryService extends GetxService { return null; } } + + Future?> getSessionHistory(String sessionId) async { + try { + final result = await _dio.get("${APIEndpoint.sessionHistory}/$sessionId"); + + final parsedResponse = BaseResponseModel.fromJson( + result.data, + (data) => SessionHistory.fromJson(data), + ); + + return parsedResponse; + } catch (e, stacktrace) { + logC.e(e, stackTrace: stacktrace); + return null; + } + } } diff --git a/lib/feature/admin_result_page/bindings/admin_result_binding.dart b/lib/feature/admin_result_page/bindings/admin_result_binding.dart new file mode 100644 index 0000000..73d8731 --- /dev/null +++ b/lib/feature/admin_result_page/bindings/admin_result_binding.dart @@ -0,0 +1,11 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/data/services/history_service.dart'; +import 'package:quiz_app/feature/admin_result_page/controller/admin_result_controller.dart'; + +class AdminResultBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => HistoryService()); + Get.lazyPut(() => AdminResultController(Get.find())); + } +} diff --git a/lib/feature/admin_result_page/controller/admin_result_controller.dart b/lib/feature/admin_result_page/controller/admin_result_controller.dart new file mode 100644 index 0000000..58b70da --- /dev/null +++ b/lib/feature/admin_result_page/controller/admin_result_controller.dart @@ -0,0 +1,32 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/data/models/history/session_history.dart'; +import 'package:quiz_app/data/services/history_service.dart'; + +class AdminResultController extends GetxController { + final HistoryService _historyService; + + AdminResultController(this._historyService); + + SessionHistory? sessionHistory; + RxBool isLoading = false.obs; + + @override + void onInit() { + loadData(); + super.onInit(); + } + + void loadData() async { + isLoading.value = true; + + final sessionId = Get.arguments as String; + final result = await _historyService.getSessionHistory(sessionId); + + if (result != null) { + sessionHistory = result.data!; + print(sessionHistory!.toJson()); + } + + isLoading.value = false; + } +} diff --git a/lib/feature/admin_result_page/view/admin_result_page.dart b/lib/feature/admin_result_page/view/admin_result_page.dart index c09a5f9..523cd26 100644 --- a/lib/feature/admin_result_page/view/admin_result_page.dart +++ b/lib/feature/admin_result_page/view/admin_result_page.dart @@ -1,263 +1,246 @@ -// import 'package:flutter/material.dart'; -// import 'package:lucide_icons/lucide_icons.dart'; -// import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'package:get/get_state_manager/src/rx_flutter/rx_obx_widget.dart'; +import 'package:get/get_state_manager/src/simple/get_view.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/app/const/text/text_style.dart'; +import 'package:quiz_app/data/models/history/session_history.dart'; +import 'package:quiz_app/feature/admin_result_page/controller/admin_result_controller.dart'; -// import 'package:quiz_app/app/const/colors/app_colors.dart'; -// import 'package:quiz_app/app/const/text/text_style.dart'; -// import 'package:quiz_app/feature/admin_result_page/view/detail_participant_result_page.dart'; +class AdminResultPage extends GetView { + const AdminResultPage({Key? key}) : super(key: key); -// class AdminResultPage extends StatelessWidget { -// const AdminResultPage({Key? key}) : super(key: key); + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } -// @override -// Widget build(BuildContext context) { -// return Scaffold( -// backgroundColor: AppColors.background, -// body: SafeArea( -// child: Padding( -// padding: const EdgeInsets.all(16.0), -// child: Column( -// crossAxisAlignment: CrossAxisAlignment.start, -// children: [ -// _buildSectionHeader("Hasil Akhir Kuis"), -// _buildSummaryCard(), -// const SizedBox(height: 20), -// _buildSectionHeader('Peringkat Peserta'), -// const SizedBox(height: 14), -// Expanded( -// child: ListView.separated( -// itemCount: dummyParticipants.length, -// separatorBuilder: (context, index) => const SizedBox(height: 10), -// itemBuilder: (context, index) { -// final participant = dummyParticipants[index]; -// return _buildParticipantResultCard( -// context, -// participant, -// position: index + 1, -// ); -// }, -// ), -// ), -// ], -// ), -// ), -// ), -// ); -// } + if (controller.sessionHistory == null) { + return const Center(child: Text("Data tidak ditemukan.")); + } -// Widget _buildSectionHeader(String title) { -// return Container( -// padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), -// decoration: BoxDecoration( -// borderRadius: BorderRadius.circular(8), -// ), -// child: Text(title, style: AppTextStyles.title), -// ); -// } + final participants = controller.sessionHistory!.participants; -// Widget _buildSummaryCard() { -// // Hitung nilai rata-rata -// final avgScore = dummyParticipants.map((p) => p.scorePercent).reduce((a, b) => a + b) / dummyParticipants.length; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader("Hasil Akhir Kuis"), + const SizedBox(height: 20), + _buildSummaryCard(participants), + const SizedBox(height: 20), + _buildSectionHeader('Peringkat Peserta'), + const SizedBox(height: 14), + Expanded( + child: ListView.separated( + itemCount: participants.length, + separatorBuilder: (context, index) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final participant = participants[index]; + return _buildParticipantResultCard( + participant, + position: index + 1, + ); + }, + ), + ), + ], + ); + }), + ), + ), + ); + } -// // Hitung jumlah peserta yang lulus (>= 60%) -// final passCount = dummyParticipants.where((p) => p.scorePercent >= 60).length; + Widget _buildSectionHeader(String title) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + ), + child: Text(title, style: AppTextStyles.title), + ); + } -// return Card( -// elevation: 2, -// color: Colors.white, -// shadowColor: AppColors.shadowPrimary.withValues(alpha:0.2), -// shape: RoundedRectangleBorder( -// borderRadius: BorderRadius.circular(16), -// side: BorderSide(color: AppColors.accentBlue.withValues(alpha:0.2)), -// ), -// child: Padding( -// padding: const EdgeInsets.all(20), -// child: Column( -// crossAxisAlignment: CrossAxisAlignment.start, -// children: [ -// Row( -// children: [ -// Icon( -// LucideIcons.clipboardCheck, -// color: AppColors.primaryBlue, -// size: 20, -// ), -// const SizedBox(width: 8), -// Text( -// "RINGKASAN KUIS", -// style: TextStyle( -// fontSize: 13, -// fontWeight: FontWeight.bold, -// color: AppColors.primaryBlue, -// letterSpacing: 0.8, -// ), -// ), -// ], -// ), -// Divider(color: AppColors.borderLight, height: 20), -// Row( -// mainAxisAlignment: MainAxisAlignment.spaceAround, -// children: [ -// _buildSummaryItem( -// icon: LucideIcons.users, -// value: "${dummyParticipants.length}", -// label: "Total Peserta", -// ), -// _buildSummaryItem( -// icon: LucideIcons.percent, -// value: "${avgScore.toStringAsFixed(1)}%", -// label: "Rata-Rata Nilai", -// valueColor: _getScoreColor(avgScore), -// ), -// _buildSummaryItem( -// icon: LucideIcons.award, -// value: "$passCount/${dummyParticipants.length}", -// label: "Peserta Lulus", -// valueColor: AppColors.scoreGood, -// ), -// ], -// ), -// ], -// ), -// ), -// ); -// } + Widget _buildSummaryCard(List participants) { + final avgScore = participants.isNotEmpty ? participants.map((p) => p.score).reduce((a, b) => a + b) / participants.length : 0.0; -// Widget _buildSummaryItem({ -// required IconData icon, -// required String value, -// required String label, -// Color? valueColor, -// }) { -// return Column( -// children: [ -// Icon(icon, color: AppColors.softGrayText, size: 22), -// const SizedBox(height: 8), -// Text( -// value, -// style: TextStyle( -// fontSize: 18, -// fontWeight: FontWeight.bold, -// color: valueColor ?? AppColors.darkText, -// ), -// ), -// const SizedBox(height: 4), -// Text( -// label, -// style: AppTextStyles.caption, -// ), -// ], -// ); -// } + final passCount = participants.where((p) => p.score >= 60).length; -// Widget _buildParticipantResultCard(BuildContext context, ParticipantResult participant, {required int position}) { -// return InkWell( -// onTap: () { -// // Navigasi ke halaman detail saat kartu ditekan -// Navigator.push( -// context, -// MaterialPageRoute( -// builder: (context) => ParticipantDetailPage(participant: participant), -// ), -// ); -// }, -// child: Card( -// elevation: 2, -// color: Colors.white, -// shadowColor: AppColors.shadowPrimary.withValues(alpha:0.2), -// shape: RoundedRectangleBorder( -// borderRadius: BorderRadius.circular(16), -// side: BorderSide(color: AppColors.accentBlue.withValues(alpha:0.2)), -// ), -// child: Padding( -// padding: const EdgeInsets.all(20), -// child: Row( -// children: [ -// // Position indicator -// Container( -// width: 36, -// height: 36, -// decoration: BoxDecoration( -// shape: BoxShape.circle, -// color: _getPositionColor(position), -// ), -// child: Center( -// child: Text( -// position.toString(), -// style: const TextStyle( -// fontSize: 16, -// fontWeight: FontWeight.bold, -// color: Colors.white, -// ), -// ), -// ), -// ), -// const SizedBox(width: 16), -// // Participant info -// Expanded( -// child: Column( -// crossAxisAlignment: CrossAxisAlignment.start, -// children: [ -// Text( -// participant.name, -// style: AppTextStyles.subtitle, -// ), -// const SizedBox(height: 4), -// Text( -// "Benar: ${participant.correctAnswers}/${participant.totalQuestions}", -// style: AppTextStyles.caption, -// ), -// ], -// ), -// ), -// // Score -// Container( -// width: 60, -// height: 60, -// decoration: BoxDecoration( -// shape: BoxShape.circle, -// color: _getScoreColor(participant.scorePercent).withValues(alpha:0.1), -// border: Border.all( -// color: _getScoreColor(participant.scorePercent), -// width: 2, -// ), -// ), -// child: Center( -// child: Text( -// "${participant.scorePercent.toInt()}%", -// style: TextStyle( -// fontSize: 16, -// fontWeight: FontWeight.bold, -// color: _getScoreColor(participant.scorePercent), -// ), -// ), -// ), -// ), -// const SizedBox(width: 12), -// // Arrow indicator -// Icon( -// LucideIcons.chevronRight, -// color: AppColors.softGrayText, -// size: 20, -// ), -// ], -// ), -// ), -// ), -// ); -// } + return Card( + elevation: 2, + color: Colors.white, + shadowColor: AppColors.shadowPrimary.withOpacity(0.2), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: AppColors.accentBlue.withOpacity(0.2)), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.assignment_turned_in, color: AppColors.primaryBlue, size: 20), + const SizedBox(width: 8), + Text( + "RINGKASAN KUIS", + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: AppColors.primaryBlue, + letterSpacing: 0.8, + ), + ), + ], + ), + const Divider(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildSummaryItem( + icon: Icons.group, + value: "${participants.length}", + label: "Total Peserta", + ), + _buildSummaryItem( + icon: Icons.percent, + value: "${avgScore.toStringAsFixed(1)}%", + label: "Rata-Rata Nilai", + valueColor: _getScoreColor(avgScore), + ), + _buildSummaryItem( + icon: Icons.emoji_events, + value: "$passCount/${participants.length}", + label: "Peserta Lulus", + valueColor: AppColors.scoreGood, + ), + ], + ), + ], + ), + ), + ); + } -// Color _getScoreColor(double score) { -// if (score >= 80) return AppColors.scoreExcellent; -// if (score >= 70) return AppColors.scoreGood; -// if (score >= 60) return AppColors.scoreAverage; -// return AppColors.scorePoor; -// } + Widget _buildSummaryItem({ + required IconData icon, + required String value, + required String label, + Color? valueColor, + }) { + return Column( + children: [ + Icon(icon, color: AppColors.softGrayText, size: 22), + const SizedBox(height: 8), + Text( + value, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: valueColor ?? AppColors.darkText, + ), + ), + const SizedBox(height: 4), + Text( + label, + style: AppTextStyles.caption, + ), + ], + ); + } -// Color _getPositionColor(int position) { -// if (position == 1) return const Color(0xFFFFD700); // Gold -// if (position == 2) return const Color(0xFFC0C0C0); // Silver -// if (position == 3) return const Color(0xFFCD7F32); // Bronze -// return AppColors.softGrayText; -// } -// } + Widget _buildParticipantResultCard(Participant participant, {required int position}) { + final scorePercent = participant.score.toDouble(); + + return Card( + elevation: 2, + color: Colors.white, + shadowColor: AppColors.shadowPrimary.withOpacity(0.2), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: AppColors.accentBlue.withOpacity(0.2)), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _getPositionColor(position), + ), + child: Center( + child: Text( + position.toString(), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(participant.name, style: AppTextStyles.subtitle), + const SizedBox(height: 4), + Text("Skor: ${participant.score}", style: AppTextStyles.caption), + ], + ), + ), + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _getScoreColor(scorePercent).withOpacity(0.1), + border: Border.all( + color: _getScoreColor(scorePercent), + width: 2, + ), + ), + child: Center( + child: Text( + "${participant.score}%", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: _getScoreColor(scorePercent), + ), + ), + ), + ), + const SizedBox(width: 12), + const Icon(Icons.chevron_right, color: AppColors.softGrayText, size: 20), + ], + ), + ), + ); + } + + Color _getScoreColor(double score) { + if (score >= 80) return AppColors.scoreExcellent; + if (score >= 70) return AppColors.scoreGood; + if (score >= 60) return AppColors.scoreAverage; + return AppColors.scorePoor; + } + + Color _getPositionColor(int position) { + if (position == 1) return const Color(0xFFFFD700); // Gold + if (position == 2) return const Color(0xFFC0C0C0); // Silver + if (position == 3) return const Color(0xFFCD7F32); // Bronze + return AppColors.softGrayText; + } +} diff --git a/lib/feature/monitor_quiz/controller/monitor_quiz_controller.dart b/lib/feature/monitor_quiz/controller/monitor_quiz_controller.dart index 62f1c1e..b0a8cab 100644 --- a/lib/feature/monitor_quiz/controller/monitor_quiz_controller.dart +++ b/lib/feature/monitor_quiz/controller/monitor_quiz_controller.dart @@ -1,4 +1,5 @@ import 'package:get/get.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/core/utils/logger.dart'; import 'package:quiz_app/data/models/user/user_model.dart'; import 'package:quiz_app/data/services/socket_service.dart'; @@ -100,6 +101,10 @@ class MonitorQuizController extends GetxController { // Notify observers participan.refresh(); }); + + _socketService.quizDone.listen((_) { + Get.offAllNamed(AppRoutes.monitorResultMPLPage, arguments: sessionId); + }); } } diff --git a/lib/feature/room_maker/controller/room_maker_controller.dart b/lib/feature/room_maker/controller/room_maker_controller.dart index 904027f..a271f36 100644 --- a/lib/feature/room_maker/controller/room_maker_controller.dart +++ b/lib/feature/room_maker/controller/room_maker_controller.dart @@ -26,26 +26,68 @@ class RoomMakerController extends GetxController { this._quizService, ); - // final roomName = ''.obs; final selectedQuiz = Rxn(); - RxBool isOnwQuiz = true.obs; final TextEditingController nameTC = TextEditingController(); final TextEditingController maxPlayerTC = TextEditingController(); + final ScrollController scrollController = ScrollController(); final availableQuizzes = [].obs; + int currentPage = 1; + bool isLoading = false; + bool hasMoreData = true; + @override void onInit() { - loadQuiz(); + loadQuiz(reset: true); + scrollController.addListener(_scrollListener); super.onInit(); } - loadQuiz() async { - BaseResponseModel>? response = await _quizService.userQuiz(_userController.userData!.id, 1); + Future loadQuiz({bool reset = false}) async { + if (isLoading) return; + + isLoading = true; + + if (reset) { + currentPage = 1; + hasMoreData = true; + } + + BaseResponseModel>? response; + + if (isOnwQuiz.value) { + response = await _quizService.userQuiz(_userController.userData!.id, currentPage); + } else { + response = await _quizService.recomendationQuiz(page: currentPage, amount: 5); + } + if (response != null) { - availableQuizzes.assignAll(response.data!); + if (reset) { + availableQuizzes.assignAll(response.data!); + } else { + availableQuizzes.addAll(response.data!); + } + + if (response.data == null || response.data!.isEmpty) { + hasMoreData = false; + } else { + currentPage++; + } + } else { + hasMoreData = false; + } + + isLoading = false; + } + + void _scrollListener() { + if (scrollController.position.pixels >= scrollController.position.maxScrollExtent - 100) { + if (hasMoreData && !isLoading) { + loadQuiz(); + } } } @@ -67,7 +109,10 @@ class RoomMakerController extends GetxController { if (response != null) { _socketService.initSocketConnection(); - _socketService.joinRoom(sessionCode: response.data!.sessionCode, userId: _userController.userData!.id); + _socketService.joinRoom( + sessionCode: response.data!.sessionCode, + userId: _userController.userData!.id, + ); _socketService.roomMessages.listen((data) { if (data["type"] == "join") { @@ -94,17 +139,7 @@ class RoomMakerController extends GetxController { void onQuizSourceChange(bool base) async { isOnwQuiz.value = base; - if (base) { - BaseResponseModel>? response = await _quizService.userQuiz(_userController.userData!.id, 1); - if (response != null) { - availableQuizzes.assignAll(response.data!); - } - return; - } - BaseResponseModel>? response = await _quizService.recomendationQuiz(page: 1, amount: 4); - if (response != null) { - availableQuizzes.assignAll(response.data!); - } + await loadQuiz(reset: true); } void onQuizChoosen(String quizId) { diff --git a/lib/feature/room_maker/view/room_maker_view.dart b/lib/feature/room_maker/view/room_maker_view.dart index bf79d92..2dd72b1 100644 --- a/lib/feature/room_maker/view/room_maker_view.dart +++ b/lib/feature/room_maker/view/room_maker_view.dart @@ -44,13 +44,25 @@ class RoomMakerView extends GetView { Expanded( child: Container( child: Obx(() => ListView.builder( - itemCount: controller.availableQuizzes.length, - itemBuilder: (context, index) { - return QuizContainerComponent( - data: controller.availableQuizzes[index], - onTap: controller.onQuizChoosen, - ); - })), + controller: controller.scrollController, + itemCount: controller.availableQuizzes.length + (controller.isLoading ? 1 : 0), + itemBuilder: (context, index) { + if (index < controller.availableQuizzes.length) { + return QuizContainerComponent( + data: controller.availableQuizzes[index], + onTap: controller.onQuizChoosen, + ); + } else { + // Loading Indicator di Bawah + return Padding( + padding: const EdgeInsets.all(16.0), + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + }, + )), ), ), SizedBox( From abe21031ecba44b34eecffd5fb09119c978a6d85 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Mon, 19 May 2025 01:42:15 +0700 Subject: [PATCH 072/104] feat: done final result on the multiplayer quiz --- lib/app/routes/app_pages.dart | 7 + lib/app/routes/app_routes.dart | 2 + lib/core/endpoint/api_endpoint.dart | 1 + .../history/participant_history_result.dart | 78 +++ lib/data/services/answer_service.dart | 19 + .../detail_participant_result_binding.dart | 11 + .../controller/admin_result_controller.dart | 10 +- .../detail_participant_result_controller.dart | 47 ++ .../view/admin_result_page.dart | 101 ++-- .../view/detail_participant_result_page.dart | 526 ++++++++++-------- 10 files changed, 521 insertions(+), 281 deletions(-) create mode 100644 lib/data/models/history/participant_history_result.dart create mode 100644 lib/feature/admin_result_page/bindings/detail_participant_result_binding.dart create mode 100644 lib/feature/admin_result_page/controller/detail_participant_result_controller.dart diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index 71c0700..81fcddc 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -1,7 +1,9 @@ import 'package:get/get_navigation/src/routes/get_route.dart'; import 'package:quiz_app/app/middleware/auth_middleware.dart'; import 'package:quiz_app/feature/admin_result_page/bindings/admin_result_binding.dart'; +import 'package:quiz_app/feature/admin_result_page/bindings/detail_participant_result_binding.dart'; import 'package:quiz_app/feature/admin_result_page/view/admin_result_page.dart'; +import 'package:quiz_app/feature/admin_result_page/view/detail_participant_result_page.dart'; import 'package:quiz_app/feature/history/binding/detail_history_binding.dart'; import 'package:quiz_app/feature/history/binding/history_binding.dart'; import 'package:quiz_app/feature/history/view/detail_history_view.dart'; @@ -148,6 +150,11 @@ class AppPages { name: AppRoutes.monitorResultMPLPage, page: () => AdminResultPage(), binding: AdminResultBinding(), + ), + GetPage( + name: AppRoutes.quizMPLResultPage, + page: () => ParticipantDetailPage(), + binding: DetailParticipantResultBinding(), ) ]; } diff --git a/lib/app/routes/app_routes.dart b/lib/app/routes/app_routes.dart index 49ebba5..c20d3af 100644 --- a/lib/app/routes/app_routes.dart +++ b/lib/app/routes/app_routes.dart @@ -27,4 +27,6 @@ abstract class AppRoutes { static const monitorResultMPLPage = "/room/quiz/monitor/result"; static const updateProfilePage = "/profile/update"; + + static const quizMPLResultPage = "/room/quiz/result"; } diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart index d33c324..b62104f 100644 --- a/lib/core/endpoint/api_endpoint.dart +++ b/lib/core/endpoint/api_endpoint.dart @@ -11,6 +11,7 @@ class APIEndpoint { static const String quiz = "/quiz"; static const String quizGenerate = "/quiz/ai"; static const String quizAnswer = "/quiz/answer"; + static const String quizAnswerSession = "/quiz/answer/session"; static const String userQuiz = "/quiz/user"; static const String quizRecomendation = "/quiz/recomendation"; diff --git a/lib/data/models/history/participant_history_result.dart b/lib/data/models/history/participant_history_result.dart new file mode 100644 index 0000000..dad6a43 --- /dev/null +++ b/lib/data/models/history/participant_history_result.dart @@ -0,0 +1,78 @@ +class QuestionAnswer { + final int index; + final String question; + final dynamic targetAnswer; + final int duration; + final String type; + final List? options; + final String answer; + final bool isCorrect; + final double timeSpent; + + QuestionAnswer({ + required this.index, + required this.question, + required this.targetAnswer, + required this.duration, + required this.type, + required this.options, + required this.answer, + required this.isCorrect, + required this.timeSpent, + }); + + factory QuestionAnswer.fromJson(Map json) { + return QuestionAnswer( + index: json['index'], + question: json['question'], + targetAnswer: json['target_answer'], + duration: json['duration'], + type: json['type'], + options: json['options'] != null ? List.from(json['options']) : null, + answer: json['answer'], + isCorrect: json['is_correct'], + timeSpent: (json['time_spent'] as num).toDouble(), + ); + } +} + +class ParticipantResult { + final String id; + final String sessionId; + final String quizId; + final String userId; + final String answeredAt; + final List answers; + final int totalScore; + final int totalCorrect; + + ParticipantResult({ + required this.id, + required this.sessionId, + required this.quizId, + required this.userId, + required this.answeredAt, + required this.answers, + required this.totalScore, + required this.totalCorrect, + }); + + factory ParticipantResult.fromJson(Map json) { + return ParticipantResult( + id: json['id'], + sessionId: json['session_id'], + quizId: json['quiz_id'], + userId: json['user_id'], + answeredAt: json['answered_at'], + answers: (json['answers'] as List).map((e) => QuestionAnswer.fromJson(e)).toList(), + totalScore: json['total_score'], + totalCorrect: json['total_correct'], + ); + } + + double get scorePercent => (totalCorrect / answers.length) * 100; + + int get totalQuestions => answers.length; + + String get name => "User $userId"; +} diff --git a/lib/data/services/answer_service.dart b/lib/data/services/answer_service.dart index cc377cd..7810f17 100644 --- a/lib/data/services/answer_service.dart +++ b/lib/data/services/answer_service.dart @@ -3,6 +3,7 @@ import 'package:get/get.dart'; import 'package:quiz_app/core/endpoint/api_endpoint.dart'; import 'package:quiz_app/core/utils/logger.dart'; import 'package:quiz_app/data/models/base/base_model.dart'; +import 'package:quiz_app/data/models/history/participant_history_result.dart'; import 'package:quiz_app/data/providers/dio_client.dart'; class AnswerService extends GetxService { @@ -26,4 +27,22 @@ class AnswerService extends GetxService { return null; } } + + Future?> getAnswerSession(String sessionId, String userId) async { + try { + final response = await _dio.post(APIEndpoint.quizAnswerSession, data: { + "session_id": sessionId, + "user_id": userId, + }); + final parsedResponse = BaseResponseModel.fromJson( + response.data, + (data) => ParticipantResult.fromJson(data), + ); + + return parsedResponse; + } on DioException catch (e) { + logC.e('Gagal mengirim jawaban: ${e.response?.data['message'] ?? e.message}'); + return null; + } + } } diff --git a/lib/feature/admin_result_page/bindings/detail_participant_result_binding.dart b/lib/feature/admin_result_page/bindings/detail_participant_result_binding.dart new file mode 100644 index 0000000..2aa6220 --- /dev/null +++ b/lib/feature/admin_result_page/bindings/detail_participant_result_binding.dart @@ -0,0 +1,11 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/data/services/answer_service.dart'; +import 'package:quiz_app/feature/admin_result_page/controller/detail_participant_result_controller.dart'; + +class DetailParticipantResultBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => AnswerService()); + Get.lazyPut(() => ParticipantResultController(Get.find())); + } +} diff --git a/lib/feature/admin_result_page/controller/admin_result_controller.dart b/lib/feature/admin_result_page/controller/admin_result_controller.dart index 58b70da..3b24dcc 100644 --- a/lib/feature/admin_result_page/controller/admin_result_controller.dart +++ b/lib/feature/admin_result_page/controller/admin_result_controller.dart @@ -1,4 +1,5 @@ import 'package:get/get.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/data/models/history/session_history.dart'; import 'package:quiz_app/data/services/history_service.dart'; @@ -10,6 +11,8 @@ class AdminResultController extends GetxController { SessionHistory? sessionHistory; RxBool isLoading = false.obs; + String sessionId = ""; + @override void onInit() { loadData(); @@ -19,7 +22,7 @@ class AdminResultController extends GetxController { void loadData() async { isLoading.value = true; - final sessionId = Get.arguments as String; + sessionId = Get.arguments as String; final result = await _historyService.getSessionHistory(sessionId); if (result != null) { @@ -29,4 +32,9 @@ class AdminResultController extends GetxController { isLoading.value = false; } + + void goToDetailParticipants(String userId, String username) => Get.toNamed( + AppRoutes.quizMPLResultPage, + arguments: {"user_id": userId, "session_id": sessionId, "username": username}, + ); } diff --git a/lib/feature/admin_result_page/controller/detail_participant_result_controller.dart b/lib/feature/admin_result_page/controller/detail_participant_result_controller.dart new file mode 100644 index 0000000..a159013 --- /dev/null +++ b/lib/feature/admin_result_page/controller/detail_participant_result_controller.dart @@ -0,0 +1,47 @@ +import 'dart:convert'; +import 'package:get/get.dart'; +import 'package:quiz_app/data/models/history/participant_history_result.dart'; +import 'package:quiz_app/data/services/answer_service.dart'; + +class ParticipantResultController extends GetxController { + final AnswerService _answerService; + + ParticipantResultController(this._answerService); + + final Rx participantResult = Rx(null); + final RxBool isLoading = false.obs; + + RxString participantName = "".obs; + + @override + void onInit() { + loadData(); + super.onInit(); + } + + void loadData() async { + isLoading.value = true; + + final args = Get.arguments; + participantName.value = args["username"]; + final response = await _answerService.getAnswerSession(args["session_id"], args["user_id"]); + + if (response != null) { + participantResult.value = response.data; + } + isLoading.value = false; + } + + double calculateScorePercent() { + if (participantResult.value == null) return 0; + return participantResult.value!.scorePercent; + } + + int getTotalCorrect() { + return participantResult.value?.totalCorrect ?? 0; + } + + int getTotalQuestions() { + return participantResult.value?.totalQuestions ?? 0; + } +} diff --git a/lib/feature/admin_result_page/view/admin_result_page.dart b/lib/feature/admin_result_page/view/admin_result_page.dart index 523cd26..9ded9a5 100644 --- a/lib/feature/admin_result_page/view/admin_result_page.dart +++ b/lib/feature/admin_result_page/view/admin_result_page.dart @@ -167,64 +167,67 @@ class AdminResultPage extends GetView { borderRadius: BorderRadius.circular(16), side: BorderSide(color: AppColors.accentBlue.withOpacity(0.2)), ), - child: Padding( - padding: const EdgeInsets.all(20), - child: Row( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _getPositionColor(position), - ), - child: Center( - child: Text( - position.toString(), - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.white, + child: InkWell( + onTap: () => controller.goToDetailParticipants(participant.id, participant.name), + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _getPositionColor(position), + ), + child: Center( + child: Text( + position.toString(), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), ), ), ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(participant.name, style: AppTextStyles.subtitle), - const SizedBox(height: 4), - Text("Skor: ${participant.score}", style: AppTextStyles.caption), - ], - ), - ), - Container( - width: 60, - height: 60, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _getScoreColor(scorePercent).withOpacity(0.1), - border: Border.all( - color: _getScoreColor(scorePercent), - width: 2, + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(participant.name, style: AppTextStyles.subtitle), + const SizedBox(height: 4), + Text("Skor: ${participant.score}", style: AppTextStyles.caption), + ], ), ), - child: Center( - child: Text( - "${participant.score}%", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _getScoreColor(scorePercent).withOpacity(0.1), + border: Border.all( color: _getScoreColor(scorePercent), + width: 2, + ), + ), + child: Center( + child: Text( + "${participant.score}%", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: _getScoreColor(scorePercent), + ), ), ), ), - ), - const SizedBox(width: 12), - const Icon(Icons.chevron_right, color: AppColors.softGrayText, size: 20), - ], + const SizedBox(width: 12), + const Icon(Icons.chevron_right, color: AppColors.softGrayText, size: 20), + ], + ), ), ), ); diff --git a/lib/feature/admin_result_page/view/detail_participant_result_page.dart b/lib/feature/admin_result_page/view/detail_participant_result_page.dart index f121b27..4ba5b66 100644 --- a/lib/feature/admin_result_page/view/detail_participant_result_page.dart +++ b/lib/feature/admin_result_page/view/detail_participant_result_page.dart @@ -1,238 +1,302 @@ -// // Halaman detail untuk peserta -// import 'package:flutter/material.dart'; -// import 'package:lucide_icons/lucide_icons.dart'; -// import 'package:quiz_app/app/const/colors/app_colors.dart'; -// import 'package:quiz_app/app/const/text/text_style.dart'; -// import 'package:quiz_app/feature/admin_result_page/view/admin_result_page.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/data/models/history/participant_history_result.dart'; +import 'package:quiz_app/feature/admin_result_page/controller/detail_participant_result_controller.dart'; -// class ParticipantDetailPage extends StatelessWidget { -// final ParticipantResult participant; +class ParticipantDetailPage extends GetView { + const ParticipantDetailPage({ + Key? key, + }) : super(key: key); -// const ParticipantDetailPage({ -// Key? key, -// required this.participant, -// }) : super(key: key); + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + title: const Text('Detail Peserta'), + backgroundColor: Colors.white, + foregroundColor: AppColors.darkText, + elevation: 0, + leading: IconButton( + icon: const Icon(LucideIcons.arrowLeft), + onPressed: () => Get.back(), + ), + ), + body: Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } -// @override -// Widget build(BuildContext context) { -// return Scaffold( -// backgroundColor: AppColors.background, -// appBar: AppBar( -// title: Text('Detail ${participant.name}'), -// backgroundColor: Colors.white, -// foregroundColor: AppColors.darkText, -// elevation: 0, -// leading: IconButton( -// icon: const Icon(LucideIcons.arrowLeft), -// onPressed: () => Navigator.pop(context), -// ), -// ), -// body: Column( -// children: [ -// _buildParticipantHeader(), -// Expanded( -// child: ListView.builder( -// padding: const EdgeInsets.all(16), -// itemCount: participant.answers.length, -// itemBuilder: (context, index) { -// return _buildAnswerCard(participant.answers[index], index + 1); -// }, -// ), -// ), -// ], -// ), -// ); -// } + final participant = controller.participantResult.value; + if (participant == null) { + return const Center(child: Text('Data peserta tidak tersedia.')); + } -// Widget _buildParticipantHeader() { -// return Container( -// width: double.infinity, -// padding: const EdgeInsets.all(16), -// decoration: BoxDecoration( -// color: Colors.white, -// border: Border( -// bottom: BorderSide( -// color: AppColors.borderLight, -// width: 1, -// ), -// ), -// ), -// child: Row( -// children: [ -// CircleAvatar( -// radius: 26, -// backgroundColor: AppColors.accentBlue, -// child: Text( -// participant.name.isNotEmpty ? participant.name[0].toUpperCase() : "?", -// style: TextStyle( -// fontSize: 22, -// fontWeight: FontWeight.bold, -// color: AppColors.primaryBlue, -// ), -// ), -// ), -// const SizedBox(width: 16), -// Expanded( -// child: Column( -// crossAxisAlignment: CrossAxisAlignment.start, -// children: [ -// Text( -// participant.name, -// style: TextStyle( -// fontSize: 16, -// fontWeight: FontWeight.bold, -// color: AppColors.darkText, -// ), -// ), -// const SizedBox(height: 4), -// Text( -// "Jumlah Soal: ${participant.totalQuestions}", -// style: TextStyle( -// fontSize: 14, -// color: AppColors.softGrayText, -// ), -// ), -// ], -// ), -// ), -// Container( -// padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), -// decoration: BoxDecoration( -// color: _getScoreColor(participant.scorePercent).withValues(alpha:0.1), -// borderRadius: BorderRadius.circular(16), -// border: Border.all( -// color: _getScoreColor(participant.scorePercent), -// ), -// ), -// child: Row( -// children: [ -// Icon( -// LucideIcons.percent, -// size: 16, -// color: _getScoreColor(participant.scorePercent), -// ), -// const SizedBox(width: 6), -// Text( -// "${participant.scorePercent.toInt()}%", -// style: TextStyle( -// fontSize: 16, -// fontWeight: FontWeight.bold, -// color: _getScoreColor(participant.scorePercent), -// ), -// ), -// ], -// ), -// ), -// ], -// ), -// ); -// } + return Column( + children: [ + _buildParticipantHeader(participant), + Expanded( + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: participant.answers.length, + itemBuilder: (context, index) { + return _buildAnswerCard(participant.answers[index], index + 1); + }, + ), + ), + ], + ); + }), + ); + } -// Widget _buildAnswerCard(QuestionAnswer answer, int number) { -// return Container( -// width: double.infinity, -// margin: const EdgeInsets.only(bottom: 20), -// padding: const EdgeInsets.all(16), -// // decoration: _containerDecoration, -// child: Column( -// crossAxisAlignment: CrossAxisAlignment.start, -// children: [ -// Row( -// crossAxisAlignment: CrossAxisAlignment.start, -// children: [ -// Container( -// width: 28, -// height: 28, -// decoration: BoxDecoration( -// shape: BoxShape.circle, -// color: answer.isCorrect ? AppColors.primaryBlue.withValues(alpha:0.1) : AppColors.scorePoor.withValues(alpha:0.1), -// ), -// child: Center( -// child: Text( -// number.toString(), -// style: TextStyle( -// fontSize: 13, -// fontWeight: FontWeight.bold, -// color: answer.isCorrect ? AppColors.primaryBlue : AppColors.scorePoor, -// ), -// ), -// ), -// ), -// const SizedBox(width: 12), -// Expanded( -// child: Text( -// 'Soal $number: ${answer.question}', -// style: const TextStyle( -// fontSize: 16, -// fontWeight: FontWeight.bold, -// color: AppColors.darkText, -// ), -// ), -// ), -// Icon( -// answer.isCorrect ? LucideIcons.checkCircle : LucideIcons.xCircle, -// color: answer.isCorrect ? AppColors.primaryBlue : AppColors.scorePoor, -// size: 20, -// ), -// ], -// ), -// const SizedBox(height: 12), -// Divider(color: AppColors.borderLight), -// const SizedBox(height: 12), -// _buildAnswerRow( -// label: "Jawaban Siswa:", -// answer: answer.userAnswer, -// isCorrect: answer.isCorrect, -// ), -// if (!answer.isCorrect) ...[ -// const SizedBox(height: 10), -// _buildAnswerRow( -// label: "Jawaban Benar:", -// answer: answer.correctAnswer, -// isCorrect: true, -// ), -// ], -// ], -// ), -// ); -// } + Widget _buildParticipantHeader(ParticipantResult participant) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + border: Border( + bottom: BorderSide( + color: AppColors.borderLight, + width: 1, + ), + ), + ), + child: Row( + children: [ + CircleAvatar( + radius: 26, + backgroundColor: AppColors.accentBlue, + child: Text( + controller.participantName.value[0].toUpperCase(), + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: AppColors.primaryBlue, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + controller.participantName.value, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.darkText, + ), + ), + const SizedBox(height: 4), + Text( + "Jumlah Soal: ${participant.totalQuestions}", + style: const TextStyle( + fontSize: 14, + color: AppColors.softGrayText, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: _getScoreColor(participant.scorePercent).withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: _getScoreColor(participant.scorePercent), + ), + ), + child: Row( + children: [ + Icon( + LucideIcons.percent, + size: 16, + color: _getScoreColor(participant.scorePercent), + ), + const SizedBox(width: 6), + Text( + "${participant.scorePercent.toInt()}%", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: _getScoreColor(participant.scorePercent), + ), + ), + ], + ), + ), + ], + ), + ); + } -// Widget _buildAnswerRow({ -// required String label, -// required String answer, -// required bool isCorrect, -// }) { -// return Row( -// crossAxisAlignment: CrossAxisAlignment.start, -// children: [ -// SizedBox( -// width: 110, -// child: Text( -// label, -// style: TextStyle( -// fontSize: 14, -// fontWeight: FontWeight.w500, -// color: AppColors.softGrayText, -// ), -// ), -// ), -// Expanded( -// child: Text( -// answer, -// style: TextStyle( -// fontSize: 15, -// fontWeight: FontWeight.w600, -// color: isCorrect ? AppColors.primaryBlue : AppColors.scorePoor, -// ), -// ), -// ), -// ], -// ); -// } + Widget _buildAnswerCard(QuestionAnswer answer, int number) { + return Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 20), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.1), + blurRadius: 6, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: answer.isCorrect ? AppColors.primaryBlue.withOpacity(0.1) : AppColors.scorePoor.withOpacity(0.1), + ), + child: Center( + child: Text( + number.toString(), + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: answer.isCorrect ? AppColors.primaryBlue : AppColors.scorePoor, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Soal $number: ${answer.question}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.darkText, + ), + ), + ), + Icon( + answer.isCorrect ? LucideIcons.checkCircle : LucideIcons.xCircle, + color: answer.isCorrect ? AppColors.primaryBlue : AppColors.scorePoor, + size: 20, + ), + ], + ), + const SizedBox(height: 12), + Divider(color: AppColors.borderLight), + const SizedBox(height: 12), + _buildAnswerRow( + label: "Tipe Soal:", + answer: answer.type, + isCorrect: true, + ), + _buildAnswerRow( + label: "Waktu Diberikan:", + answer: "${answer.duration} detik", + isCorrect: true, + ), + _buildAnswerRow( + label: "Waktu Dihabiskan:", + answer: "${answer.timeSpent} detik", + isCorrect: true, + ), + _buildAnswerRow( + label: "Jawaban Siswa:", + answer: answer.answer, + isCorrect: answer.isCorrect, + ), + if (!answer.isCorrect) ...[ + const SizedBox(height: 10), + _buildAnswerRow( + label: "Jawaban Benar:", + answer: answer.targetAnswer.toString(), + isCorrect: true, + ), + ], + if (answer.options != null) ...[ + const SizedBox(height: 10), + _buildOptions(answer.options!), + ], + ], + ), + ); + } -// Color _getScoreColor(double score) { -// if (score >= 70) return AppColors.scoreGood; -// if (score >= 60) return AppColors.scoreAverage; -// return AppColors.scorePoor; -// } -// } + Widget _buildOptions(List options) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Pilihan Jawaban:", + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.softGrayText, + ), + ), + const SizedBox(height: 6), + ...options.map((opt) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Text( + "- $opt", + style: const TextStyle( + fontSize: 15, + color: AppColors.darkText, + ), + ), + )), + ], + ); + } + + Widget _buildAnswerRow({ + required String label, + required String answer, + required bool isCorrect, + }) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 110, + child: Text( + label, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.softGrayText, + ), + ), + ), + Expanded( + child: Text( + answer, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: isCorrect ? AppColors.primaryBlue : AppColors.scorePoor, + ), + ), + ), + ], + ); + } + + Color _getScoreColor(double score) { + if (score >= 70) return AppColors.scoreGood; + if (score >= 60) return AppColors.scoreAverage; + return AppColors.scorePoor; + } +} From 15e4a9295cafdbb38445ac961bb10b720d04cf3e Mon Sep 17 00:00:00 2001 From: akhdanre Date: Mon, 19 May 2025 02:09:30 +0700 Subject: [PATCH 073/104] fix: navigation on the quiz multiplayer and the result page --- .../controller/admin_result_controller.dart | 7 +- .../detail_participant_result_controller.dart | 20 ++++- .../view/detail_participant_result_page.dart | 88 ++++++++++++------- .../monitor_quiz/view/monitor_quiz_view.dart | 69 ++++++++------- .../controller/play_quiz_controller.dart | 14 ++- .../view/play_quiz_multiplayer.dart | 30 +++---- 6 files changed, 142 insertions(+), 86 deletions(-) diff --git a/lib/feature/admin_result_page/controller/admin_result_controller.dart b/lib/feature/admin_result_page/controller/admin_result_controller.dart index 3b24dcc..c309f68 100644 --- a/lib/feature/admin_result_page/controller/admin_result_controller.dart +++ b/lib/feature/admin_result_page/controller/admin_result_controller.dart @@ -35,6 +35,11 @@ class AdminResultController extends GetxController { void goToDetailParticipants(String userId, String username) => Get.toNamed( AppRoutes.quizMPLResultPage, - arguments: {"user_id": userId, "session_id": sessionId, "username": username}, + arguments: { + "user_id": userId, + "session_id": sessionId, + "username": username, + "is_admin": true, + }, ); } diff --git a/lib/feature/admin_result_page/controller/detail_participant_result_controller.dart b/lib/feature/admin_result_page/controller/detail_participant_result_controller.dart index a159013..6f11dd6 100644 --- a/lib/feature/admin_result_page/controller/detail_participant_result_controller.dart +++ b/lib/feature/admin_result_page/controller/detail_participant_result_controller.dart @@ -1,5 +1,5 @@ -import 'dart:convert'; import 'package:get/get.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/data/models/history/participant_history_result.dart'; import 'package:quiz_app/data/services/answer_service.dart'; @@ -12,6 +12,7 @@ class ParticipantResultController extends GetxController { final RxBool isLoading = false.obs; RxString participantName = "".obs; + bool isAdmin = false; @override void onInit() { @@ -24,6 +25,7 @@ class ParticipantResultController extends GetxController { final args = Get.arguments; participantName.value = args["username"]; + isAdmin = args["is_admin"]; final response = await _answerService.getAnswerSession(args["session_id"], args["user_id"]); if (response != null) { @@ -44,4 +46,20 @@ class ParticipantResultController extends GetxController { int getTotalQuestions() { return participantResult.value?.totalQuestions ?? 0; } + + void goBackPage() { + if (isAdmin) { + Get.back(); + } else { + Get.offAllNamed(AppRoutes.mainPage); + } + } + + void onPop(bool isPop, dynamic value) { + if (isAdmin) { + Get.back(); + } else { + Get.offAllNamed(AppRoutes.mainPage); + } + } } diff --git a/lib/feature/admin_result_page/view/detail_participant_result_page.dart b/lib/feature/admin_result_page/view/detail_participant_result_page.dart index 4ba5b66..0534908 100644 --- a/lib/feature/admin_result_page/view/detail_participant_result_page.dart +++ b/lib/feature/admin_result_page/view/detail_participant_result_page.dart @@ -12,43 +12,63 @@ class ParticipantDetailPage extends GetView { @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.background, - appBar: AppBar( - title: const Text('Detail Peserta'), - backgroundColor: Colors.white, - foregroundColor: AppColors.darkText, - elevation: 0, - leading: IconButton( - icon: const Icon(LucideIcons.arrowLeft), - onPressed: () => Get.back(), - ), - ), - body: Obx(() { - if (controller.isLoading.value) { - return const Center(child: CircularProgressIndicator()); - } + return PopScope( + canPop: false, + onPopInvokedWithResult: controller.onPop, + child: Scaffold( + backgroundColor: AppColors.background, + body: Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } - final participant = controller.participantResult.value; - if (participant == null) { - return const Center(child: Text('Data peserta tidak tersedia.')); - } + final participant = controller.participantResult.value; + if (participant == null) { + return const Center(child: Text('Data peserta tidak tersedia.')); + } - return Column( - children: [ - _buildParticipantHeader(participant), - Expanded( - child: ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: participant.answers.length, - itemBuilder: (context, index) { - return _buildAnswerCard(participant.answers[index], index + 1); - }, - ), + return SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + color: Colors.white, + child: Row( + children: [ + IconButton( + icon: const Icon(LucideIcons.arrowLeft), + color: AppColors.darkText, + onPressed: controller.goBackPage, + ), + const SizedBox(width: 8), + const Text( + 'Detail Peserta', + style: TextStyle( + color: AppColors.darkText, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + // Body Content + _buildParticipantHeader(participant), + Expanded( + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: participant.answers.length, + itemBuilder: (context, index) { + return _buildAnswerCard(participant.answers[index], index + 1); + }, + ), + ), + ], ), - ], - ); - }), + ); + }), + ), ); } diff --git a/lib/feature/monitor_quiz/view/monitor_quiz_view.dart b/lib/feature/monitor_quiz/view/monitor_quiz_view.dart index 57c7788..6ebccb9 100644 --- a/lib/feature/monitor_quiz/view/monitor_quiz_view.dart +++ b/lib/feature/monitor_quiz/view/monitor_quiz_view.dart @@ -11,42 +11,45 @@ class MonitorQuizView extends GetView { @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.background, - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildSectionHeader("Monitor Admin"), - Obx(() => _buildCurrentQuestion( - questionText: controller.currentQuestion.value, - )), - const SizedBox(height: 24), - _buildSectionHeader('Daftar Peserta'), - const SizedBox(height: 16), - Expanded( - child: Obx( - () => ListView.separated( - itemCount: controller.participan.length, - separatorBuilder: (context, index) => const SizedBox(height: 12), - itemBuilder: (context, index) { - final student = controller.participan[index]; - final totalAnswers = student.correct.value + student.wrong.value; - final progressPercent = totalAnswers > 0 ? student.correct.value / totalAnswers : 0.0; + return PopScope( + canPop: false, + child: Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader("Monitor Admin"), + Obx(() => _buildCurrentQuestion( + questionText: controller.currentQuestion.value, + )), + const SizedBox(height: 24), + _buildSectionHeader('Daftar Peserta'), + const SizedBox(height: 16), + Expanded( + child: Obx( + () => ListView.separated( + itemCount: controller.participan.length, + separatorBuilder: (context, index) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final student = controller.participan[index]; + final totalAnswers = student.correct.value + student.wrong.value; + final progressPercent = totalAnswers > 0 ? student.correct.value / totalAnswers : 0.0; - return _buildStudentCard( - name: student.name, - totalBenar: student.correct.value, - totalSalah: student.wrong.value, - progressPercent: progressPercent, - ); - }, + return _buildStudentCard( + name: student.name, + totalBenar: student.correct.value, + totalSalah: student.wrong.value, + progressPercent: progressPercent, + ); + }, + ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart b/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart index f7f7ba3..083973b 100644 --- a/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart +++ b/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/component/global_button.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/services/socket_service.dart'; @@ -129,10 +130,19 @@ class PlayQuizMultiplayerController extends GetxController { } } + void goToDetailResult() { + Get.offAllNamed(AppRoutes.quizMPLResultPage, arguments: { + "user_id": _userController.userData!.id, + "session_id": sessionId, + "username": _userController.userName.value, + "is_admin": false, + }); + } + @override void onClose() { fillInAnswerController.dispose(); - _cancelTimer(); // Important: cancel timer when controller is closed + _cancelTimer(); super.onClose(); } } @@ -140,7 +150,7 @@ class PlayQuizMultiplayerController extends GetxController { class MultiplayerQuestionModel { final int questionIndex; final String question; - final String type; // 'option', 'true_false', 'fill_in_the_blank' + final String type; final int duration; final List? options; diff --git a/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart b/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart index afc32f9..6981b05 100644 --- a/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart +++ b/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart @@ -7,19 +7,22 @@ import 'package:quiz_app/feature/play_quiz_multiplayer/controller/play_quiz_cont class PlayQuizMultiplayerView extends GetView { @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: const Color(0xFFF9FAFB), - body: Obx(() { - if (controller.isDone.value) { - return _buildDoneView(); - } + return PopScope( + canPop: false, + child: Scaffold( + backgroundColor: const Color(0xFFF9FAFB), + body: Obx(() { + if (controller.isDone.value) { + return _buildDoneView(); + } - if (controller.currentQuestion.value == null) { - return const Center(child: CircularProgressIndicator()); - } + if (controller.currentQuestion.value == null) { + return const Center(child: CircularProgressIndicator()); + } - return _buildQuestionView(); - }), + return _buildQuestionView(); + }), + ), ); } @@ -245,10 +248,7 @@ class PlayQuizMultiplayerView extends GetView { ), const SizedBox(height: 40), ElevatedButton( - onPressed: () { - // Arahkan ke halaman hasil atau leaderboard - Get.back(); - }, + onPressed: controller.goToDetailResult, style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF2563EB), foregroundColor: Colors.white, From fab876b4ecfe816586b04b6d5fe14a22587aaaaf Mon Sep 17 00:00:00 2001 From: akhdanre Date: Mon, 19 May 2025 03:08:45 +0700 Subject: [PATCH 074/104] feat: adding unit test on the auth and subject service --- lib/app/app.dart | 1 + lib/data/services/auth_service.dart | 11 +- lib/data/services/subject_service.dart | 32 ++-- .../home/controller/home_controller.dart | 9 +- .../controller/quiz_preview_controller.dart | 14 +- .../search/controller/search_controller.dart | 9 +- pubspec.lock | 128 +-------------- pubspec.yaml | 2 +- test/service/auth_service_test.dart | 150 ++++++++++++++++++ test/test_helper/auth_service_test.mock.dart | 8 + .../subject_service_test_mock.dart | 83 ++++++++++ 11 files changed, 285 insertions(+), 162 deletions(-) create mode 100644 test/service/auth_service_test.dart create mode 100644 test/test_helper/auth_service_test.mock.dart create mode 100644 test/test_helper/subject_service_test_mock.dart diff --git a/lib/app/app.dart b/lib/app/app.dart index e1cf154..da3b46f 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -18,6 +18,7 @@ class MyApp extends StatelessWidget { initialBinding: InitialBindings(), initialRoute: AppRoutes.splashScreen, getPages: AppPages.routes, + debugShowCheckedModeBanner: false, ); } } diff --git a/lib/data/services/auth_service.dart b/lib/data/services/auth_service.dart index c2bfad6..1922006 100644 --- a/lib/data/services/auth_service.dart +++ b/lib/data/services/auth_service.dart @@ -1,4 +1,3 @@ - import 'package:dio/dio.dart'; import 'package:get/get.dart'; import 'package:quiz_app/core/endpoint/api_endpoint.dart'; @@ -9,16 +8,16 @@ import 'package:quiz_app/data/models/register/register_request.dart'; import 'package:quiz_app/data/providers/dio_client.dart'; class AuthService extends GetxService { - late final Dio _dio; + late final Dio dio; @override void onInit() { - _dio = Get.find().dio; + dio = Get.find().dio; super.onInit(); } Future register(RegisterRequestModel request) async { - var data = await _dio.post( + var data = await dio.post( APIEndpoint.register, data: request.toJson(), ); @@ -31,7 +30,7 @@ class AuthService extends GetxService { Future loginWithEmail(LoginRequestModel request) async { final data = request.toJson(); - final response = await _dio.post(APIEndpoint.login, data: data); + final response = await dio.post(APIEndpoint.login, data: data); if (response.statusCode == 200) { print(response.data); @@ -46,7 +45,7 @@ class AuthService extends GetxService { } Future loginWithGoogle(String idToken) async { - final response = await _dio.post( + final response = await dio.post( APIEndpoint.loginGoogle, data: {"token_id": idToken}, ); diff --git a/lib/data/services/subject_service.dart b/lib/data/services/subject_service.dart index 6cd95ef..83579dd 100644 --- a/lib/data/services/subject_service.dart +++ b/lib/data/services/subject_service.dart @@ -1,39 +1,31 @@ import 'package:dio/dio.dart'; import 'package:get/get.dart'; import 'package:quiz_app/core/endpoint/api_endpoint.dart'; -import 'package:quiz_app/core/utils/logger.dart'; import 'package:quiz_app/data/models/base/base_model.dart'; import 'package:quiz_app/data/models/subject/subject_model.dart'; import 'package:quiz_app/data/providers/dio_client.dart'; class SubjectService extends GetxService { - late final Dio _dio; + late final Dio dio; @override void onInit() { - _dio = Get.find().dio; + dio = Get.find().dio; super.onInit(); } - Future>?> getSubject() async { - try { - final response = await _dio.get( - APIEndpoint.subject, + Future>> getSubject() async { + final response = await dio.get(APIEndpoint.subject); + + if (response.statusCode == 200) { + final parsedResponse = BaseResponseModel>.fromJson( + response.data, + (data) => (data as List).map((e) => SubjectModel.fromJson(e as Map)).toList(), ); - if (response.statusCode == 200) { - final parsedResponse = BaseResponseModel>.fromJson( - response.data, - (data) => (data as List).map((e) => SubjectModel.fromJson(e as Map)).toList(), - ); - - return parsedResponse; - } else { - return null; - } - } catch (e) { - logC.e("Quiz creation error: $e"); - return null; + return parsedResponse; + } else { + throw Exception('Failed to fetch subjects. Status code: ${response.statusCode}'); } } } diff --git a/lib/feature/home/controller/home_controller.dart b/lib/feature/home/controller/home_controller.dart index d1e3bec..ecdc870 100644 --- a/lib/feature/home/controller/home_controller.dart +++ b/lib/feature/home/controller/home_controller.dart @@ -1,6 +1,7 @@ import 'package:get/get.dart'; import 'package:quiz_app/app/const/enums/listing_type.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/core/utils/logger.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/models/base/base_model.dart'; import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; @@ -42,9 +43,11 @@ class HomeController extends GetxController { } void loadSubjectData() async { - BaseResponseModel>? respnse = await _subjectService.getSubject(); - if (respnse != null) { - subjects.assignAll(respnse.data!); + try { + final response = await _subjectService.getSubject(); + subjects.assignAll(response.data!); + } catch (e) { + logC.e(e); } } diff --git a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart index e9dad1b..c6e5336 100644 --- a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart +++ b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart @@ -6,7 +6,6 @@ import 'package:quiz_app/core/utils/custom_floating_loading.dart'; import 'package:quiz_app/core/utils/custom_notification.dart'; import 'package:quiz_app/core/utils/logger.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; -import 'package:quiz_app/data/models/base/base_model.dart'; import 'package:quiz_app/data/models/quiz/question_create_request.dart'; import 'package:quiz_app/data/models/quiz/question_listings_model.dart'; import 'package:quiz_app/data/models/quiz/quiestion_data_model.dart'; @@ -57,10 +56,15 @@ class QuizPreviewController extends GetxController { } void loadSubjectData() async { - BaseResponseModel>? respnse = await _subjectService.getSubject(); - if (respnse != null) { - subjects.assignAll(respnse.data!); - subjectId = subjects[0].id; + try { + final response = await _subjectService.getSubject(); + subjects.assignAll(response.data!); + + if (subjects.isNotEmpty) { + subjectId = subjects[0].id; + } + } catch (e) { + logC.e(e); } } diff --git a/lib/feature/search/controller/search_controller.dart b/lib/feature/search/controller/search_controller.dart index d71062a..1759638 100644 --- a/lib/feature/search/controller/search_controller.dart +++ b/lib/feature/search/controller/search_controller.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/const/enums/listing_type.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/core/utils/logger.dart'; import 'package:quiz_app/data/models/base/base_model.dart'; import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; import 'package:quiz_app/data/models/subject/subject_model.dart'; @@ -63,9 +64,11 @@ class SearchQuizController extends GetxController { ); void loadSubjectData() async { - BaseResponseModel>? respnse = await _subjectService.getSubject(); - if (respnse != null) { - subjects.assignAll(respnse.data!); + try { + final response = await _subjectService.getSubject(); + subjects.assignAll(response.data!); + } catch (e) { + logC.e("Failed to load subjects: $e"); } } diff --git a/pubspec.lock b/pubspec.lock index 6045306..11e8610 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,22 +1,6 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: e55636ed79578b9abca5fecf9437947798f5ef7456308b5cb85720b793eac92f - url: "https://pub.dev" - source: hosted - version: "82.0.0" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: "904ae5bb474d32c38fb9482e2d925d5454cda04ddd0e55d2e6826bc72f6ba8c0" - url: "https://pub.dev" - source: hosted - version: "7.4.5" args: dependency: transitive description: @@ -41,30 +25,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" - build: - dependency: transitive - description: - name: build - sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 - url: "https://pub.dev" - source: hosted - version: "2.4.2" - built_collection: - dependency: transitive - description: - name: built_collection - sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" - url: "https://pub.dev" - source: hosted - version: "5.1.1" - built_value: - dependency: transitive - description: - name: built_value - sha256: ea90e81dc4a25a043d9bee692d20ed6d1c4a1662a28c03a96417446c093ed6b4 - url: "https://pub.dev" - source: hosted - version: "8.9.5" characters: dependency: transitive description: @@ -81,14 +41,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - code_builder: - dependency: transitive - description: - name: code_builder - sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" - url: "https://pub.dev" - source: hosted - version: "4.10.1" collection: dependency: transitive description: @@ -97,14 +49,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.0" - convert: - dependency: transitive - description: - name: convert - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.dev" - source: hosted - version: "3.1.2" crypto: dependency: transitive description: @@ -121,14 +65,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" - dart_style: - dependency: transitive - description: - name: dart_style - sha256: "27eb0ae77836989a3bc541ce55595e8ceee0992807f14511552a898ddd0d88ac" - url: "https://pub.dev" - source: hosted - version: "3.0.1" dio: dependency: "direct main" description: @@ -185,14 +121,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" - source: hosted - version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -237,14 +165,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.6.6" - glob: - dependency: transitive - description: - name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de - url: "https://pub.dev" - source: hosted - version: "2.1.3" google_fonts: dependency: "direct main" description: @@ -405,22 +325,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.15.0" - mockito: + mocktail: dependency: "direct dev" description: - name: mockito - sha256: f99d8d072e249f719a5531735d146d8cf04c580d93920b04de75bef6dfb2daf6 + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" url: "https://pub.dev" source: hosted - version: "5.4.5" - package_config: - dependency: transitive - description: - name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc - url: "https://pub.dev" - source: hosted - version: "2.2.0" + version: "1.0.4" path: dependency: transitive description: @@ -501,14 +413,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.dev" - source: hosted - version: "2.2.0" shared_preferences: dependency: "direct main" description: @@ -586,14 +490,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.1" - source_gen: - dependency: transitive - description: - name: source_gen - sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" - url: "https://pub.dev" - source: hosted - version: "2.0.0" source_span: dependency: transitive description: @@ -666,14 +562,6 @@ packages: url: "https://pub.dev" source: hosted version: "14.3.0" - watcher: - dependency: transitive - description: - name: watcher - sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" - url: "https://pub.dev" - source: hosted - version: "1.1.1" web: dependency: transitive description: @@ -690,14 +578,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" - yaml: - dependency: transitive - description: - name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.dev" - source: hosted - version: "3.1.3" sdks: dart: ">=3.6.0 <4.0.0" flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml index 0c07a01..56cd005 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -49,7 +49,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - mockito: ^5.4.4 + mocktail: ^1.0.4 # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is diff --git a/test/service/auth_service_test.dart b/test/service/auth_service_test.dart new file mode 100644 index 0000000..a4ed0d4 --- /dev/null +++ b/test/service/auth_service_test.dart @@ -0,0 +1,150 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:dio/dio.dart'; +import 'package:quiz_app/core/endpoint/api_endpoint.dart'; +import 'package:quiz_app/data/models/login/login_request_model.dart'; +import 'package:quiz_app/data/models/register/register_request.dart'; +import 'package:quiz_app/data/services/auth_service.dart'; + +class MockDio extends Mock implements Dio {} + +void main() { + late MockDio mockDio; + late AuthService authService; + + setUp(() { + mockDio = MockDio(); + authService = AuthService(); + authService.dio = mockDio; + }); + + group('AuthService with Mocktail (Using Real Data)', () { + test('Register Success', () async { + final request = RegisterRequestModel( + email: 'danakhdan@gmail.com', + password: '123456', + name: 'Akhdan Robbani', + birthDate: '12-07-2002', + ); + + when(() => mockDio.post(APIEndpoint.register, data: request.toJson())).thenAnswer((_) async => Response( + statusCode: 200, + data: {}, + requestOptions: RequestOptions(path: APIEndpoint.register), + )); + + final result = await authService.register(request); + + expect(result, true); + verify(() => mockDio.post(APIEndpoint.register, data: request.toJson())).called(1); + }); + + test('Register Failure', () async { + final request = RegisterRequestModel( + email: 'danakhdan@gmail.com', + password: '123456', + name: 'Akhdan Robbani', + birthDate: '12-07-2002', + ); + + when(() => mockDio.post(APIEndpoint.register, data: request.toJson())).thenAnswer((_) async => Response( + statusCode: 400, + data: {'message': 'Registration failed'}, + requestOptions: RequestOptions(path: APIEndpoint.register), + )); + + expect(() => authService.register(request), throwsException); + verify(() => mockDio.post(APIEndpoint.register, data: request.toJson())).called(1); + }); + + test('Login With Email Success', () async { + final request = LoginRequestModel(email: 'danakhdan@gmail.com', password: '123456'); + final responseData = { + 'message': 'Login success', + 'data': { + 'id': 'lkasjd93093j4oi234n1234', + 'email': 'danakhdan@gmail.com', + 'name': 'Akhdan Robbani', + 'birth_date': '2002-08-13 07:00:00', + 'pic_url': 'https://example.com/akhdan.png', + 'phone': '081234567890', + 'locale': 'id-ID', + }, + 'meta': null, + }; + + when(() => mockDio.post(APIEndpoint.login, data: request.toJson())).thenAnswer((_) async => Response( + statusCode: 200, + data: responseData, + requestOptions: RequestOptions(path: APIEndpoint.login), + )); + + final result = await authService.loginWithEmail(request); + + expect(result.name, 'Akhdan Robbani'); + expect(result.email, 'danakhdan@gmail.com'); + expect(result.birthDate, DateTime.parse('2002-08-13 07:00:00')); + expect(result.phone, '081234567890'); + expect(result.locale, 'id-ID'); + verify(() => mockDio.post(APIEndpoint.login, data: request.toJson())).called(1); + }); + + test('Login With Email Failure - Invalid Credentials', () async { + final request = LoginRequestModel(email: 'danakhdan@gmail.com', password: 'wrongpassword'); + + when(() => mockDio.post(APIEndpoint.login, data: request.toJson())).thenAnswer((_) async => Response( + statusCode: 401, + data: {'message': 'Invalid credentials'}, + requestOptions: RequestOptions(path: APIEndpoint.login), + )); + + expect(() => authService.loginWithEmail(request), throwsException); + verify(() => mockDio.post(APIEndpoint.login, data: request.toJson())).called(1); + }); + + test('Login With Google Success', () async { + final idToken = 'valid_google_token'; + final responseData = { + 'message': 'Login success', + 'data': { + 'id': '680e5a6d2f480bd75db17a09', + 'email': 'danakhdan@gmail.com', + 'name': 'Akhdan Robbani', + 'birth_date': '2002-08-13 07:00:00', + 'pic_url': null, + 'phone': '081234567890', + 'locale': 'id-ID' + }, + 'meta': null + }; + + when(() => mockDio.post(APIEndpoint.loginGoogle, data: {"token_id": idToken})).thenAnswer((_) async => Response( + statusCode: 200, + data: responseData, + requestOptions: RequestOptions(path: APIEndpoint.loginGoogle), + )); + + final result = await authService.loginWithGoogle(idToken); + + expect(result.name, 'Akhdan Robbani'); + expect(result.email, 'danakhdan@gmail.com'); + expect(result.birthDate, DateTime.parse('2002-08-13 07:00:00')); + expect(result.phone, '081234567890'); + expect(result.locale, 'id-ID'); + verify(() => mockDio.post(APIEndpoint.loginGoogle, data: {"token_id": idToken})).called(1); + }); + + test('Login With Google Failure - Invalid Token', () async { + final idToken = 'invalid_google_token'; + + when(() => mockDio.post(APIEndpoint.loginGoogle, data: {"token_id": idToken})).thenAnswer((_) async => Response( + statusCode: 401, + data: {'message': 'Invalid Google token'}, + requestOptions: RequestOptions(path: APIEndpoint.loginGoogle), + )); + + expect(() => authService.loginWithGoogle(idToken), throwsException); + verify(() => mockDio.post(APIEndpoint.loginGoogle, data: {"token_id": idToken})).called(1); + }); + }); +} diff --git a/test/test_helper/auth_service_test.mock.dart b/test/test_helper/auth_service_test.mock.dart new file mode 100644 index 0000000..af25380 --- /dev/null +++ b/test/test_helper/auth_service_test.mock.dart @@ -0,0 +1,8 @@ +// // Create this in a separate file, e.g., auth_service_test.mocks.dart + +// import 'package:dio/dio.dart'; +// import 'package:mockito/annotations.dart'; +// import 'package:quiz_app/data/providers/dio_client.dart'; + +// @GenerateMocks([Dio, ApiClient]) +// void main() {} diff --git a/test/test_helper/subject_service_test_mock.dart b/test/test_helper/subject_service_test_mock.dart new file mode 100644 index 0000000..a909c9e --- /dev/null +++ b/test/test_helper/subject_service_test_mock.dart @@ -0,0 +1,83 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:dio/dio.dart'; +import 'package:quiz_app/core/endpoint/api_endpoint.dart'; +import 'package:quiz_app/data/services/subject_service.dart'; + +class MockDio extends Mock implements Dio {} + +void main() { + late MockDio mockDio; + late SubjectService subjectService; + + setUp(() { + mockDio = MockDio(); + subjectService = SubjectService(); + subjectService.dio = mockDio; + }); + + group('SubjectService Tests', () { + test('getSubject - Success', () async { + final responseData = { + 'message': 'Subjects fetched successfully', + 'data': [ + { + 'id': 'subject1', + 'name': 'Mathematics', + }, + { + 'id': 'subject2', + 'name': 'Science', + }, + ], + 'meta': null, + }; + + when(() => mockDio.get(APIEndpoint.subject)).thenAnswer((_) async => Response( + statusCode: 200, + data: responseData, + requestOptions: RequestOptions(path: APIEndpoint.subject), + )); + + final result = await subjectService.getSubject(); + + expect(result.data!.length, 2); + expect(result.data![0].id, 'subject1'); + expect(result.data![0].name, 'Mathematics'); + expect(result.data![1].id, 'subject2'); + expect(result.data![1].name, 'Science'); + + verify(() => mockDio.get(APIEndpoint.subject)).called(1); + }); + + test('getSubject 400 validation issue', () async { + when(() => mockDio.get(APIEndpoint.subject)).thenAnswer((_) async => Response( + statusCode: 400, + data: {'message': 'Bad Request'}, + requestOptions: RequestOptions(path: APIEndpoint.subject), + )); + + expect( + () => subjectService.getSubject(), + throwsA(isA()), + ); + + verify(() => mockDio.get(APIEndpoint.subject)).called(1); + }); + + test('getSubject (Network Error)', () async { + when(() => mockDio.get(APIEndpoint.subject)).thenThrow(DioException( + requestOptions: RequestOptions(path: APIEndpoint.subject), + error: 'Network Error', + type: DioExceptionType.connectionError, + )); + + expect( + () => subjectService.getSubject(), + throwsA(isA()), + ); + + verify(() => mockDio.get(APIEndpoint.subject)).called(1); + }); + }); +} From 048410786b5d7319c065600ff0cc738a9b8d14bd Mon Sep 17 00:00:00 2001 From: akhdanre Date: Mon, 19 May 2025 03:09:06 +0700 Subject: [PATCH 075/104] feat: adding unit test on the auth and subject service --- test/{test_helper => service}/subject_service_test_mock.dart | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/{test_helper => service}/subject_service_test_mock.dart (100%) diff --git a/test/test_helper/subject_service_test_mock.dart b/test/service/subject_service_test_mock.dart similarity index 100% rename from test/test_helper/subject_service_test_mock.dart rename to test/service/subject_service_test_mock.dart From e3d2cbb7a6def5491890f361d8db5486c6fb7b87 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Wed, 21 May 2025 22:29:22 +0700 Subject: [PATCH 076/104] fix: adjustment on the notification and loading --- android/app/build.gradle | 13 ++ lib/core/endpoint/api_endpoint.dart | 4 +- lib/data/services/answer_service.dart | 8 +- lib/data/services/quiz_service.dart | 16 +-- .../login/controllers/login_controller.dart | 3 +- .../controller/register_controller.dart | 6 + test/service/answer_service_test.dart | 128 ++++++++++++++++++ test/service/quiz_service_test.dart | 114 ++++++++++++++++ 8 files changed, 277 insertions(+), 15 deletions(-) create mode 100644 test/service/answer_service_test.dart create mode 100644 test/service/quiz_service_test.dart diff --git a/android/app/build.gradle b/android/app/build.gradle index c913db7..2cfd4a7 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -38,6 +38,15 @@ android { storePassword = "uppercase12" } + + release { + keyAlias = "genso-prod" + keyPassword = "oukenzeumasio" + storeFile = file("my-release-key.jks") + storePassword = "oukenzeumasio" + + } + } buildTypes { @@ -46,6 +55,10 @@ android { // Signing with the debug keys for now, so `flutter run --release` works. signingConfig = signingConfigs.debug } + + release { + signingConfig = signingConfigs.release + } } } diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart index b62104f..0aa29d4 100644 --- a/lib/core/endpoint/api_endpoint.dart +++ b/lib/core/endpoint/api_endpoint.dart @@ -1,6 +1,6 @@ class APIEndpoint { - static const String baseUrl = "http://192.168.1.9:5000"; - // static const String baseUrl = "http://172.16.106.133:5000"; + // static const String baseUrl = "http://192.168.1.9:5000"; + static const String baseUrl = "http://103.193.178.121:5000"; static const String api = "$baseUrl/api"; static const String login = "/login"; diff --git a/lib/data/services/answer_service.dart b/lib/data/services/answer_service.dart index 7810f17..79b344a 100644 --- a/lib/data/services/answer_service.dart +++ b/lib/data/services/answer_service.dart @@ -7,17 +7,17 @@ import 'package:quiz_app/data/models/history/participant_history_result.dart'; import 'package:quiz_app/data/providers/dio_client.dart'; class AnswerService extends GetxService { - late final Dio _dio; + late final Dio dio; @override void onInit() { - _dio = Get.find().dio; + dio = Get.find().dio; super.onInit(); } Future submitQuizAnswers(Map payload) async { try { - await _dio.post( + await dio.post( APIEndpoint.quizAnswer, data: payload, ); @@ -30,7 +30,7 @@ class AnswerService extends GetxService { Future?> getAnswerSession(String sessionId, String userId) async { try { - final response = await _dio.post(APIEndpoint.quizAnswerSession, data: { + final response = await dio.post(APIEndpoint.quizAnswerSession, data: { "session_id": sessionId, "user_id": userId, }); diff --git a/lib/data/services/quiz_service.dart b/lib/data/services/quiz_service.dart index c1146fe..80cfa4e 100644 --- a/lib/data/services/quiz_service.dart +++ b/lib/data/services/quiz_service.dart @@ -9,17 +9,17 @@ import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; import 'package:quiz_app/data/providers/dio_client.dart'; class QuizService extends GetxService { - late final Dio _dio; + late final Dio dio; @override void onInit() { - _dio = Get.find().dio; + dio = Get.find().dio; super.onInit(); } Future createQuiz(QuizCreateRequestModel request) async { try { - final response = await _dio.post( + final response = await dio.post( APIEndpoint.quiz, data: request.toJson(), ); @@ -37,7 +37,7 @@ class QuizService extends GetxService { Future>> createQuizAuto(String sentence) async { try { - final response = await _dio.post( + final response = await dio.post( APIEndpoint.quizGenerate, data: {"sentence": sentence}, ); @@ -63,7 +63,7 @@ class QuizService extends GetxService { Future>?> userQuiz(String userId, int page) async { try { - final response = await _dio.get("${APIEndpoint.userQuiz}/$userId?page=$page"); + final response = await dio.get("${APIEndpoint.userQuiz}/$userId?page=$page"); if (response.statusCode == 200) { final parsedResponse = BaseResponseModel>.fromJson( response.data, @@ -82,7 +82,7 @@ class QuizService extends GetxService { Future>?> recomendationQuiz({int page = 1, int amount = 3}) async { try { - final response = await _dio.get("${APIEndpoint.quizRecomendation}?page=$page&limit=$amount"); + final response = await dio.get("${APIEndpoint.quizRecomendation}?page=$page&limit=$amount"); if (response.statusCode == 200) { final parsedResponse = BaseResponseModel>.fromJson( @@ -110,7 +110,7 @@ class QuizService extends GetxService { }; final uri = Uri.parse(APIEndpoint.quizSearch).replace(queryParameters: queryParams); - final response = await _dio.getUri(uri); + final response = await dio.getUri(uri); if (response.statusCode == 200) { final parsedResponse = BaseResponseModel>.fromJson( @@ -130,7 +130,7 @@ class QuizService extends GetxService { Future?> getQuizById(String quizId) async { try { - final response = await _dio.get("${APIEndpoint.quiz}/$quizId"); + final response = await dio.get("${APIEndpoint.quiz}/$quizId"); if (response.statusCode == 200) { final parsedResponse = BaseResponseModel.fromJson( diff --git a/lib/feature/login/controllers/login_controller.dart b/lib/feature/login/controllers/login_controller.dart index 7b4da7f..e512f05 100644 --- a/lib/feature/login/controllers/login_controller.dart +++ b/lib/feature/login/controllers/login_controller.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/component/global_button.dart'; +import 'package:quiz_app/core/utils/custom_notification.dart'; import 'package:quiz_app/core/utils/logger.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/entity/user/user_entity.dart'; @@ -74,7 +75,7 @@ class LoginController extends GetxController { Get.offAllNamed(AppRoutes.mainPage); } catch (e, stackTrace) { logC.e(e, stackTrace: stackTrace); - Get.snackbar("Error", "Failed to connect to server"); + CustomNotification.error(title: "failed", message: "Check username and password"); } finally { isLoading.value = false; } diff --git a/lib/feature/register/controller/register_controller.dart b/lib/feature/register/controller/register_controller.dart index 5428b9d..010cec3 100644 --- a/lib/feature/register/controller/register_controller.dart +++ b/lib/feature/register/controller/register_controller.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/core/utils/custom_floating_loading.dart'; +import 'package:quiz_app/core/utils/custom_notification.dart'; import 'package:quiz_app/data/models/register/register_request.dart'; import 'package:quiz_app/data/services/auth_service.dart'; @@ -59,6 +61,7 @@ class RegisterController extends GetxController { } try { + CustomFloatingLoading.showLoadingDialog(Get.context!); await _authService.register( RegisterRequestModel( email: email, @@ -68,7 +71,10 @@ class RegisterController extends GetxController { phone: phone, ), ); + Get.back(); + CustomFloatingLoading.hideLoadingDialog(Get.context!); + CustomNotification.success(title: "register success", message: "created account successfuly"); } catch (e) { Get.snackbar("Error", "Failed to register: ${e.toString()}"); } diff --git a/test/service/answer_service_test.dart b/test/service/answer_service_test.dart new file mode 100644 index 0000000..da20384 --- /dev/null +++ b/test/service/answer_service_test.dart @@ -0,0 +1,128 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:dio/dio.dart'; +import 'package:quiz_app/core/endpoint/api_endpoint.dart'; +import 'package:quiz_app/data/models/base/base_model.dart'; +import 'package:quiz_app/data/services/answer_service.dart'; + +class MockDio extends Mock implements Dio {} + +void main() { + late MockDio mockDio; + late AnswerService answerService; + + setUp(() { + mockDio = MockDio(); + answerService = AnswerService(); + answerService.dio = mockDio; + }); + + group('AnswerService Tests', () { + test('submitQuizAnswers - Success', () async { + final payload = {'question_id': 'q1', 'answer': 'A'}; + + when(() => mockDio.post(APIEndpoint.quizAnswer, data: payload)).thenAnswer((_) async => Response( + statusCode: 200, + data: {}, + requestOptions: RequestOptions(path: APIEndpoint.quizAnswer), + )); + + final result = await answerService.submitQuizAnswers(payload); + + expect(result, isA()); + expect(result?.message, 'success'); + + verify(() => mockDio.post(APIEndpoint.quizAnswer, data: payload)).called(1); + }); + + test('submitQuizAnswers - Failure', () async { + final payload = {'question_id': 'q1', 'answer': 'A'}; + + when(() => mockDio.post(APIEndpoint.quizAnswer, data: payload)).thenThrow(DioException( + requestOptions: RequestOptions(path: APIEndpoint.quizAnswer), + error: 'Network Error', + type: DioExceptionType.connectionError, + )); + + final result = await answerService.submitQuizAnswers(payload); + + expect(result, isNull); + verify(() => mockDio.post(APIEndpoint.quizAnswer, data: payload)).called(1); + }); + + test('getAnswerSession - Success', () async { + final sessionId = '682a26b3bedac6c20a215452'; + final userId = '680f0e63180b5c19b3751d42'; + final responseData = { + "message": "Successfully retrieved the answer", + "data": { + "id": "682a26e6bedac6c20a215453", + "session_id": "682a26b3bedac6c20a215452", + "quiz_id": "682a120f18339f4cc31318e4", + "user_id": "680f0e63180b5c19b3751d42", + "answered_at": "2025-05-19 01:28:22", + "answers": [ + { + "index": 1, + "question": "Siapakah ketua Wali Songo yang juga dikenal sebagai Sunan Gresik?", + "target_answer": "Maulana Malik Ibrahim", + "duration": 30, + "type": "fill_the_blank", + "options": null, + "answer": "maulana Malik ibrahim", + "is_correct": true, + "time_spent": 8.0 + } + ], + "total_score": 100, + "total_correct": 1 + }, + "meta": null + }; + + when(() => mockDio.post(APIEndpoint.quizAnswerSession, data: { + "session_id": sessionId, + "user_id": userId, + })).thenAnswer((_) async => Response( + statusCode: 200, + data: responseData, + requestOptions: RequestOptions(path: APIEndpoint.quizAnswerSession), + )); + + final result = await answerService.getAnswerSession(sessionId, userId); + + expect(result, isNotNull); + expect(result?.data?.sessionId, sessionId); + expect(result?.data?.userId, userId); + expect(result?.data?.totalScore, 100); + + verify(() => mockDio.post(APIEndpoint.quizAnswerSession, data: { + "session_id": sessionId, + "user_id": userId, + })).called(1); + }); + + test('getAnswerSession - Failure', () async { + final sessionId = ''; + final userId = ''; + + when(() => mockDio.post(APIEndpoint.quizAnswerSession, data: { + "session_id": sessionId, + "user_id": userId, + })).thenThrow(DioException( + requestOptions: RequestOptions(path: APIEndpoint.quizAnswerSession), + error: 'Network Error', + type: DioExceptionType.connectionError, + )); + + final result = await answerService.getAnswerSession(sessionId, userId); + + expect(result, isNull); + + verify(() => mockDio.post(APIEndpoint.quizAnswerSession, data: { + "session_id": sessionId, + "user_id": userId, + })).called(1); + }); + }); +} diff --git a/test/service/quiz_service_test.dart b/test/service/quiz_service_test.dart new file mode 100644 index 0000000..139bbd6 --- /dev/null +++ b/test/service/quiz_service_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:dio/dio.dart'; +import 'package:quiz_app/data/models/quiz/question_listings_model.dart'; +import 'package:quiz_app/data/providers/dio_client.dart'; +import 'package:quiz_app/data/services/quiz_service.dart'; +import 'package:quiz_app/data/models/quiz/question_create_request.dart'; + +class MockDio extends Mock implements Dio {} + +class MockApiClient extends Mock implements ApiClient {} + +void main() { + late MockDio mockDio; + late QuizService quizService; + + setUp(() { + mockDio = MockDio(); + quizService = QuizService(); + quizService.dio = mockDio; + }); + + group('createQuiz', () { + final request = QuizCreateRequestModel( + title: 'Test Quiz', + description: 'A sample quiz description', + isPublic: true, + date: '2025-05-19', + totalQuiz: 1, + limitDuration: 60, + authorId: 'author_123', + subjectId: 'subject_456', + questionListings: [ + QuestionListing( + index: 1, + question: 'Sample question?', + targetAnswer: 'Sample Answer', + duration: 30, + type: 'multiple_choice', + ) + ], + ); + + test('returns true when status code is 201', () async { + when(() => mockDio.post(any(), data: any(named: 'data'))).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: ''), + statusCode: 201, + ), + ); + + final result = await quizService.createQuiz(request); + expect(result, true); + }); + + test('throws Exception on non-201 response', () async { + when(() => mockDio.post(any(), data: any(named: 'data'))).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: ''), + statusCode: 400, + ), + ); + + expect(() => quizService.createQuiz(request), throwsException); + }); + + test('throws Exception on Dio error', () async { + when(() => mockDio.post(any(), data: any(named: 'data'))).thenThrow(Exception('Network Error')); + + expect(() => quizService.createQuiz(request), throwsException); + }); + }); + + group('createQuizAuto', () { + const sentence = "This is a test sentence."; + final mockResponseData = { + 'message': "succes create quiz automatic", + 'data': [ + {'qustion': 'What is this?', 'answer': 'A test.'}, + ] + }; + + test('returns BaseResponseModel when status code is 200', () async { + when(() => mockDio.post(any(), data: any(named: 'data'))).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: ''), + statusCode: 200, + data: mockResponseData, + ), + ); + + final result = await quizService.createQuizAuto(sentence); + expect(result.data, isA>()); + expect(result.data!.first.qustion, 'What is this?'); + }); + + test('throws Exception on non-200 response', () async { + when(() => mockDio.post(any(), data: any(named: 'data'))).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: ''), + statusCode: 500, + ), + ); + + expect(() => quizService.createQuizAuto(sentence), throwsException); + }); + + test('throws Exception on Dio error', () async { + when(() => mockDio.post(any(), data: any(named: 'data'))).thenThrow(Exception('Network Error')); + + expect(() => quizService.createQuizAuto(sentence), throwsException); + }); + }); +} From bfd959a5df94c4c7a237f8aa45370f8e86ed9430 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Fri, 23 May 2025 13:42:51 +0700 Subject: [PATCH 077/104] fix: notification setup --- .../question/true_false_question_model.dart | 4 +- lib/data/services/auth_service.dart | 53 +++++++++++-------- .../detail_quiz/view/detail_quix_view.dart | 2 +- .../login/controllers/login_controller.dart | 8 +-- .../controller/register_controller.dart | 19 ++++--- 5 files changed, 52 insertions(+), 34 deletions(-) diff --git a/lib/data/models/quiz/question/true_false_question_model.dart b/lib/data/models/quiz/question/true_false_question_model.dart index 0a0717e..af01464 100644 --- a/lib/data/models/quiz/question/true_false_question_model.dart +++ b/lib/data/models/quiz/question/true_false_question_model.dart @@ -11,11 +11,13 @@ class TrueFalseQuestion extends BaseQuestionModel { }) : super(type: 'true_false'); factory TrueFalseQuestion.fromJson(Map json) { + print(json['target_answer']); + return TrueFalseQuestion( index: json['index'], question: json['question'], duration: json['duration'], - targetAnswer: json['target_answer'], + targetAnswer: json['target_answer'].toString().toLowerCase() == 'true', ); } diff --git a/lib/data/services/auth_service.dart b/lib/data/services/auth_service.dart index 1922006..cf1de61 100644 --- a/lib/data/services/auth_service.dart +++ b/lib/data/services/auth_service.dart @@ -17,47 +17,58 @@ class AuthService extends GetxService { } Future register(RegisterRequestModel request) async { - var data = await dio.post( - APIEndpoint.register, - data: request.toJson(), - ); - if (data.statusCode == 200) { - return true; - } else { - throw Exception("Registration failed"); + try { + final response = await dio.post( + APIEndpoint.register, + data: request.toJson(), + ); + + return response.statusCode == 200; + } on DioException catch (e) { + if (e.response?.statusCode == 409) { + // Status 409 = Conflict = User already exists + throw Exception("Email sudah dipakai"); + } + + // Other Dio errors + final errorMessage = e.response?.data['message'] ?? "Pendaftaran gagal"; + throw Exception(errorMessage); + } catch (e) { + throw Exception("Terjadi kesalahan saat mendaftar"); } } Future loginWithEmail(LoginRequestModel request) async { - final data = request.toJson(); - final response = await dio.post(APIEndpoint.login, data: data); + try { + final data = request.toJson(); + final response = await dio.post(APIEndpoint.login, data: data); - if (response.statusCode == 200) { - print(response.data); final baseResponse = BaseResponseModel.fromJson( response.data, (json) => LoginResponseModel.fromJson(json), ); return baseResponse.data!; - } else { - throw Exception("Login failed"); + } on DioException catch (e) { + final errorMessage = e.response?.data['message'] ?? "Login gagal"; + throw Exception(errorMessage); } } Future loginWithGoogle(String idToken) async { - final response = await dio.post( - APIEndpoint.loginGoogle, - data: {"token_id": idToken}, - ); + try { + final response = await dio.post( + APIEndpoint.loginGoogle, + data: {"token_id": idToken}, + ); - if (response.statusCode == 200) { final baseResponse = BaseResponseModel.fromJson( response.data, (json) => LoginResponseModel.fromJson(json), ); return baseResponse.data!; - } else { - throw Exception("Google login failed"); + } on DioException catch (e) { + final errorMessage = e.response?.data['message'] ?? "Login Google gagal"; + throw Exception(errorMessage); } } } diff --git a/lib/feature/detail_quiz/view/detail_quix_view.dart b/lib/feature/detail_quiz/view/detail_quix_view.dart index 5042b96..b597eda 100644 --- a/lib/feature/detail_quiz/view/detail_quix_view.dart +++ b/lib/feature/detail_quiz/view/detail_quix_view.dart @@ -77,7 +77,7 @@ class DetailQuizView extends GetView { GlobalButton(text: "Kerjakan", onPressed: controller.goToPlayPage), const SizedBox(height: 20), - GlobalButton(text: "buat ruangan", onPressed: () {}), + // GlobalButton(text: "buat ruangan", onPressed: () {}), const SizedBox(height: 20), const Divider(thickness: 1.2, color: AppColors.borderLight), diff --git a/lib/feature/login/controllers/login_controller.dart b/lib/feature/login/controllers/login_controller.dart index e512f05..682200b 100644 --- a/lib/feature/login/controllers/login_controller.dart +++ b/lib/feature/login/controllers/login_controller.dart @@ -55,7 +55,7 @@ class LoginController extends GetxController { final password = passwordController.text.trim(); if (email.isEmpty || password.isEmpty) { - Get.snackbar("Error", "Email and password are required"); + Get.snackbar("Kesalahan", "Email dan kata sandi wajib diisi"); return; } @@ -75,7 +75,7 @@ class LoginController extends GetxController { Get.offAllNamed(AppRoutes.mainPage); } catch (e, stackTrace) { logC.e(e, stackTrace: stackTrace); - CustomNotification.error(title: "failed", message: "Check username and password"); + CustomNotification.error(title: "Gagal", message: "Periksa kembali email dan kata sandi Anda"); } finally { isLoading.value = false; } @@ -86,13 +86,13 @@ class LoginController extends GetxController { try { final user = await _googleAuthService.signIn(); if (user == null) { - Get.snackbar("Error", "Google Sign-In canceled"); + Get.snackbar("Kesalahan", "Masuk dengan Google dibatalkan"); return; } final idToken = await user.authentication.then((auth) => auth.idToken); if (idToken == null || idToken.isEmpty) { - Get.snackbar("Error", "No ID Token received."); + Get.snackbar("Kesalahan", "Tidak menerima ID Token dari Google"); return; } diff --git a/lib/feature/register/controller/register_controller.dart b/lib/feature/register/controller/register_controller.dart index 010cec3..79b2db1 100644 --- a/lib/feature/register/controller/register_controller.dart +++ b/lib/feature/register/controller/register_controller.dart @@ -37,26 +37,27 @@ class RegisterController extends GetxController { String phone = phoneController.text.trim(); if (email.isEmpty || password.isEmpty || confirmPassword.isEmpty || name.isEmpty || birthDate.isEmpty) { - Get.snackbar("Error", "All fields are required"); + CustomNotification.error(title: "Kesalahan", message: "Semua data harus diisi"); return; } if (!_isValidEmail(email)) { - Get.snackbar("Error", "Invalid email format"); + CustomNotification.error(title: "Kesalahan", message: "Format email tidak valid"); return; } if (!_isValidDateFormat(birthDate)) { - Get.snackbar("Error", "Invalid date format. Use dd-mm-yyyy"); + CustomNotification.error(title: "Kesalahan", message: "Format tanggal tidak valid. Gunakan format seperti ini 12-09-2003"); return; } + if (password != confirmPassword) { - Get.snackbar("Error", "Passwords do not match"); + CustomNotification.error(title: "Kesalahan", message: "Kata sandi tidak cocok"); return; } if (phone.isNotEmpty && (phone.length < 10 || phone.length > 13)) { - Get.snackbar("Error", "Phone number must be between 10 and 13 digits"); + CustomNotification.error(title: "Kesalahan", message: "Nomor telepon harus terdiri dari 10 hingga 13 digit"); return; } @@ -74,9 +75,13 @@ class RegisterController extends GetxController { Get.back(); CustomFloatingLoading.hideLoadingDialog(Get.context!); - CustomNotification.success(title: "register success", message: "created account successfuly"); + CustomNotification.success(title: "Pendaftaran Berhasil", message: "Akun berhasil dibuat"); } catch (e) { - Get.snackbar("Error", "Failed to register: ${e.toString()}"); + CustomFloatingLoading.hideLoadingDialog(Get.context!); + + String errorMessage = e.toString().replaceFirst("Exception: ", ""); + + CustomNotification.error(title: "Pendaftaran gagal", message: errorMessage); } } From bfa253ceec69113deb764ee00a5af6696f1dc6fd Mon Sep 17 00:00:00 2001 From: akhdanre Date: Fri, 23 May 2025 15:04:04 +0700 Subject: [PATCH 078/104] fix: register page --- lib/app/bindings/initial_bindings.dart | 2 + lib/core/helper/connection_check.dart | 17 +++ lib/data/services/connection_service.dart | 56 +++++++++ lib/feature/login/bindings/login_binding.dart | 11 +- .../login/controllers/login_controller.dart | 32 ++++- lib/feature/login/view/login_page.dart | 2 +- .../register/binding/register_binding.dart | 8 +- .../controller/register_controller.dart | 21 +++- lib/feature/register/view/register_page.dart | 117 +++++++++++------- pubspec.lock | 48 +++++++ pubspec.yaml | 1 + 11 files changed, 264 insertions(+), 51 deletions(-) create mode 100644 lib/core/helper/connection_check.dart create mode 100644 lib/data/services/connection_service.dart diff --git a/lib/app/bindings/initial_bindings.dart b/lib/app/bindings/initial_bindings.dart index 189bb2e..cc212a4 100644 --- a/lib/app/bindings/initial_bindings.dart +++ b/lib/app/bindings/initial_bindings.dart @@ -1,12 +1,14 @@ import 'package:get/get.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/providers/dio_client.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; import 'package:quiz_app/data/services/user_storage_service.dart'; class InitialBindings extends Bindings { @override void dependencies() { Get.put(UserStorageService()); + Get.put(ConnectionService()); Get.putAsync(() => ApiClient().init()); Get.put(UserController(Get.find())); } diff --git a/lib/core/helper/connection_check.dart b/lib/core/helper/connection_check.dart new file mode 100644 index 0000000..a35d433 --- /dev/null +++ b/lib/core/helper/connection_check.dart @@ -0,0 +1,17 @@ +import 'package:quiz_app/core/utils/custom_notification.dart'; + +class ConnectionNotification { + static void internetConnected() { + CustomNotification.success( + title: "Terkoneksi kembali", + message: "Terhubugn dengan koneksi", + ); + } + + static void noInternedConnection() { + CustomNotification.error( + title: "Tidak ada internet", + message: "cek kembali koneksi internet kamu", + ); + } +} diff --git a/lib/data/services/connection_service.dart b/lib/data/services/connection_service.dart new file mode 100644 index 0000000..de5609f --- /dev/null +++ b/lib/data/services/connection_service.dart @@ -0,0 +1,56 @@ +import 'dart:async'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:get/get.dart'; + +/// [ConnectionService] is a GetX Service that monitors internet connectivity status. +/// +/// It utilizes the [Connectivity] class from the `connectivity_plus` package. +class ConnectionService extends GetxService { + final Connectivity _connectivity = Connectivity(); + + /// Subscription to the connectivity change stream. + late StreamSubscription> _subscription; + + /// Reactive boolean to indicate the current internet connection status. + /// `true` means the device is connected to the internet via Wi-Fi, mobile data, or other means. + final RxBool isConnected = true.obs; + + bool get isCurrentlyConnected => isConnected.value; + + /// Called when the service is first initialized. + @override + void onInit() { + super.onInit(); + _initConnectivity(); + _subscription = _connectivity.onConnectivityChanged.listen(_updateConnectionStatus); + } + + /// Checks the initial connectivity status when the service is initialized. + Future _initConnectivity() async { + try { + final result = await _connectivity.checkConnectivity(); + _updateConnectionStatus(result); // Wrap in a list for consistency + } catch (e) { + isConnected.value = false; + } + } + + /// Callback function to handle changes in connectivity status. + /// @param results A list of [ConnectivityResult] representing all active network connections. + void _updateConnectionStatus(List results) { + // If all results are `none`, the device is considered offline. + isConnected.value = results.any((result) => result != ConnectivityResult.none); + } + + Future checkConnection() async { + final result = await _connectivity.checkConnectivity(); + return !result.contains(ConnectivityResult.none); + } + + /// Cancels the connectivity subscription when the service is closed. + @override + void onClose() { + _subscription.cancel(); + super.onClose(); + } +} diff --git a/lib/feature/login/bindings/login_binding.dart b/lib/feature/login/bindings/login_binding.dart index 9686a88..3e05bcc 100644 --- a/lib/feature/login/bindings/login_binding.dart +++ b/lib/feature/login/bindings/login_binding.dart @@ -2,6 +2,7 @@ import 'package:get/get_core/get_core.dart'; import 'package:get/get_instance/get_instance.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/services/auth_service.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; import 'package:quiz_app/data/services/google_auth_service.dart'; import 'package:quiz_app/data/services/user_storage_service.dart'; import 'package:quiz_app/feature/login/controllers/login_controller.dart'; @@ -11,6 +12,14 @@ class LoginBinding extends Bindings { void dependencies() { Get.lazyPut(() => GoogleAuthService()); Get.lazyPut(() => AuthService()); - Get.lazyPut(() => LoginController(Get.find(), Get.find(), Get.find(), Get.find())); + Get.lazyPut( + () => LoginController( + Get.find(), + Get.find(), + Get.find(), + Get.find(), + Get.find(), + ), + ); } } diff --git a/lib/feature/login/controllers/login_controller.dart b/lib/feature/login/controllers/login_controller.dart index 682200b..2e4c0a0 100644 --- a/lib/feature/login/controllers/login_controller.dart +++ b/lib/feature/login/controllers/login_controller.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/component/global_button.dart'; +import 'package:quiz_app/core/helper/connection_check.dart'; import 'package:quiz_app/core/utils/custom_notification.dart'; import 'package:quiz_app/core/utils/logger.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; @@ -9,6 +10,7 @@ import 'package:quiz_app/data/entity/user/user_entity.dart'; import 'package:quiz_app/data/models/login/login_request_model.dart'; import 'package:quiz_app/data/models/login/login_response_model.dart'; import 'package:quiz_app/data/services/auth_service.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; import 'package:quiz_app/data/services/google_auth_service.dart'; import 'package:quiz_app/data/services/user_storage_service.dart'; @@ -17,12 +19,14 @@ class LoginController extends GetxController { final UserStorageService _userStorageService; final UserController _userController; final GoogleAuthService _googleAuthService; + final ConnectionService _connectionService; LoginController( this._authService, this._userStorageService, this._userController, this._googleAuthService, + this._connectionService, ); final TextEditingController emailController = TextEditingController(); @@ -37,8 +41,26 @@ class LoginController extends GetxController { super.onInit(); emailController.addListener(validateFields); passwordController.addListener(validateFields); + + ever(_connectionService.isConnected, (value) { + if (!value) { + ConnectionNotification.noInternedConnection(); + } else { + ConnectionNotification.internetConnected(); + } + }); } + @override + void onReady() { + if (!_connectionService.isCurrentlyConnected) { + ConnectionNotification.noInternedConnection(); + } + super.onReady(); + } + + void checkConnection() async {} + void validateFields() { final isEmailNotEmpty = emailController.text.trim().isNotEmpty; final isPasswordNotEmpty = passwordController.text.trim().isNotEmpty; @@ -59,6 +81,10 @@ class LoginController extends GetxController { return; } + if (!_connectionService.isCurrentlyConnected) { + ConnectionNotification.noInternedConnection(); + return; + } try { isLoading.value = true; @@ -81,8 +107,11 @@ class LoginController extends GetxController { } } - /// **🔹 Login via Google** Future loginWithGoogle() async { + if (!_connectionService.isCurrentlyConnected) { + ConnectionNotification.noInternedConnection(); + return; + } try { final user = await _googleAuthService.signIn(); if (user == null) { @@ -112,7 +141,6 @@ class LoginController extends GetxController { void goToRegsPage() => Get.toNamed(AppRoutes.registerPage); - /// Helper untuk convert LoginResponseModel ke UserEntity UserEntity _convertLoginResponseToUserEntity(LoginResponseModel response) { logC.i("user id : ${response.id}"); return UserEntity( diff --git a/lib/feature/login/view/login_page.dart b/lib/feature/login/view/login_page.dart index 74f8f0f..2011f91 100644 --- a/lib/feature/login/view/login_page.dart +++ b/lib/feature/login/view/login_page.dart @@ -16,7 +16,7 @@ class LoginView extends GetView { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: AppColors.background, // background soft clean + backgroundColor: AppColors.background, body: SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), diff --git a/lib/feature/register/binding/register_binding.dart b/lib/feature/register/binding/register_binding.dart index f00bd23..ab49d20 100644 --- a/lib/feature/register/binding/register_binding.dart +++ b/lib/feature/register/binding/register_binding.dart @@ -1,11 +1,17 @@ import 'package:get/get.dart'; import 'package:quiz_app/data/services/auth_service.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; import 'package:quiz_app/feature/register/controller/register_controller.dart'; class RegisterBinding extends Bindings { @override void dependencies() { Get.lazyPut(() => AuthService()); - Get.lazyPut(() => RegisterController(Get.find())); + Get.lazyPut( + () => RegisterController( + Get.find(), + Get.find(), + ), + ); } } diff --git a/lib/feature/register/controller/register_controller.dart b/lib/feature/register/controller/register_controller.dart index 79b2db1..506e211 100644 --- a/lib/feature/register/controller/register_controller.dart +++ b/lib/feature/register/controller/register_controller.dart @@ -1,14 +1,20 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/core/helper/connection_check.dart'; import 'package:quiz_app/core/utils/custom_floating_loading.dart'; import 'package:quiz_app/core/utils/custom_notification.dart'; import 'package:quiz_app/data/models/register/register_request.dart'; import 'package:quiz_app/data/services/auth_service.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; class RegisterController extends GetxController { final AuthService _authService; + final ConnectionService _connectionService; - RegisterController(this._authService); + RegisterController( + this._authService, + this._connectionService, + ); final TextEditingController nameController = TextEditingController(); final TextEditingController bDateController = TextEditingController(); @@ -20,6 +26,14 @@ class RegisterController extends GetxController { var isPasswordHidden = true.obs; var isConfirmPasswordHidden = true.obs; + @override + void onReady() { + if (!_connectionService.isCurrentlyConnected) { + ConnectionNotification.noInternedConnection(); + } + super.onReady(); + } + void togglePasswordVisibility() { isPasswordHidden.value = !isPasswordHidden.value; } @@ -29,6 +43,11 @@ class RegisterController extends GetxController { } Future onRegister() async { + if (!_connectionService.isCurrentlyConnected) { + ConnectionNotification.noInternedConnection(); + return; + } + String email = emailController.text.trim(); String name = nameController.text.trim(); String birthDate = bDateController.text.trim(); diff --git a/lib/feature/register/view/register_page.dart b/lib/feature/register/view/register_page.dart index 6a72f87..a749110 100644 --- a/lib/feature/register/view/register_page.dart +++ b/lib/feature/register/view/register_page.dart @@ -18,54 +18,62 @@ class RegisterView extends GetView { body: SafeArea( child: Padding( padding: const EdgeInsets.all(16.0), - child: ListView( + child: Column( children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 40), - child: AppName(), - ), - LabelTextField( - label: context.tr('register_title'), - fontSize: 24, - ), - const SizedBox(height: 10), - LabelTextField(label: context.tr('full_name')), - GlobalTextField(controller: controller.nameController), - const SizedBox(height: 10), - LabelTextField(label: context.tr('email')), - GlobalTextField(controller: controller.emailController), - const SizedBox(height: 10), - LabelTextField(label: context.tr('birth_date')), - GlobalTextField( - controller: controller.bDateController, - hintText: "12-08-2001", - ), - LabelTextField(label: context.tr('phone_optional')), - GlobalTextField( - controller: controller.phoneController, - hintText: "085708570857", - ), - const SizedBox(height: 10), - LabelTextField(label: context.tr('password')), - Obx( - () => GlobalTextField( - controller: controller.passwordController, - isPassword: true, - obscureText: controller.isPasswordHidden.value, - onToggleVisibility: controller.togglePasswordVisibility, + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + buildBackHeader(), + const SizedBox(height: 30), + AppName(), + const SizedBox(height: 40), + LabelTextField( + label: context.tr('register_title'), + fontSize: 24, + ), + const SizedBox(height: 10), + LabelTextField(label: context.tr('full_name')), + GlobalTextField(controller: controller.nameController), + const SizedBox(height: 10), + LabelTextField(label: context.tr('email')), + GlobalTextField(controller: controller.emailController), + const SizedBox(height: 10), + LabelTextField(label: context.tr('birth_date')), + GlobalTextField( + controller: controller.bDateController, + hintText: "12-08-2001", + ), + LabelTextField(label: context.tr('phone_optional')), + GlobalTextField( + controller: controller.phoneController, + hintText: "085708570857", + ), + const SizedBox(height: 10), + LabelTextField(label: context.tr('password')), + Obx( + () => GlobalTextField( + controller: controller.passwordController, + isPassword: true, + obscureText: controller.isPasswordHidden.value, + onToggleVisibility: controller.togglePasswordVisibility, + ), + ), + const SizedBox(height: 10), + LabelTextField(label: context.tr('verify_password')), + Obx( + () => GlobalTextField( + controller: controller.confirmPasswordController, + isPassword: true, + obscureText: controller.isConfirmPasswordHidden.value, + onToggleVisibility: controller.toggleConfirmPasswordVisibility, + ), + ), + ], + ), ), ), - const SizedBox(height: 10), - LabelTextField(label: context.tr('verify_password')), - Obx( - () => GlobalTextField( - controller: controller.confirmPasswordController, - isPassword: true, - obscureText: controller.isConfirmPasswordHidden.value, - onToggleVisibility: controller.toggleConfirmPasswordVisibility, - ), - ), - const SizedBox(height: 40), + const SizedBox(height: 20), GlobalButton( onPressed: controller.onRegister, text: context.tr('register_button'), @@ -76,4 +84,23 @@ class RegisterView extends GetView { ), ); } + + Widget buildBackHeader({String title = ""}) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Get.back(), + ), + Text( + title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ], + ); + } } diff --git a/pubspec.lock b/pubspec.lock index 11e8610..2ec5508 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -49,6 +49,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.0" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: "051849e2bd7c7b3bc5844ea0d096609ddc3a859890ec3a9ac4a65a2620cc1f99" + url: "https://pub.dev" + source: hosted + version: "6.1.4" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" + url: "https://pub.dev" + source: hosted + version: "2.0.1" crypto: dependency: transitive description: @@ -65,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.dev" + source: hosted + version: "0.7.11" dio: dependency: "direct main" description: @@ -333,6 +357,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" path: dependency: transitive description: @@ -397,6 +429,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.2.5" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://pub.dev" + source: hosted + version: "6.0.2" platform: dependency: transitive description: @@ -578,6 +618,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" sdks: dart: ">=3.6.0 <4.0.0" flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml index 56cd005..c190f99 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,6 +45,7 @@ dependencies: socket_io_client: ^3.1.2 easy_localization: ^3.0.7+1 percent_indicator: ^4.2.5 + connectivity_plus: ^6.1.4 dev_dependencies: flutter_test: From 1555ce5558398bc86617b88476751c749cb4c9aa Mon Sep 17 00:00:00 2001 From: akhdanre Date: Fri, 23 May 2025 19:09:53 +0700 Subject: [PATCH 079/104] feat: adjust interface on the quiz play --- lib/core/endpoint/api_endpoint.dart | 4 +- .../quiz_play/view/quiz_play_view.dart | 531 +++++++++++++++--- 2 files changed, 448 insertions(+), 87 deletions(-) diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart index 0aa29d4..0b2528c 100644 --- a/lib/core/endpoint/api_endpoint.dart +++ b/lib/core/endpoint/api_endpoint.dart @@ -1,6 +1,6 @@ class APIEndpoint { - // static const String baseUrl = "http://192.168.1.9:5000"; - static const String baseUrl = "http://103.193.178.121:5000"; + static const String baseUrl = "http://192.168.1.9:5000"; + // static const String baseUrl = "http://103.193.178.121:5000"; static const String api = "$baseUrl/api"; static const String login = "/login"; diff --git a/lib/feature/quiz_play/view/quiz_play_view.dart b/lib/feature/quiz_play/view/quiz_play_view.dart index 929f277..73e538f 100644 --- a/lib/feature/quiz_play/view/quiz_play_view.dart +++ b/lib/feature/quiz_play/view/quiz_play_view.dart @@ -13,16 +13,12 @@ class QuizPlayView extends GetView { Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFF9FAFB), - // appBar: _buildAppBar(), body: SafeArea( child: Padding( padding: const EdgeInsets.all(16), child: Obx(() { if (!controller.isStarting.value) { - return Text( - context.tr('ready_in', namedArgs: {'second': controller.prepareDuration.toString()}), - style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold), - ); + return _buildCountdownScreen(context); } return Column( @@ -36,8 +32,7 @@ class QuizPlayView extends GetView { const SizedBox(height: 12), _buildQuestionText(), const SizedBox(height: 30), - _buildAnswerSection(context), - const Spacer(), + Expanded(child: _buildAnswerSection(context)), _buildNextButton(context), ], ); @@ -47,23 +42,123 @@ class QuizPlayView extends GetView { ); } - Widget _buildCustomAppBar(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: const BoxDecoration( - color: Colors.transparent, - ), - child: Row( + Widget _buildCountdownScreen(BuildContext context) { + return Center( + child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ + TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), + duration: const Duration(milliseconds: 800), + builder: (context, value, child) { + return Transform.scale( + scale: 0.5 + (value * 0.5), + child: Container( + width: 120, + height: 120, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.primaryBlue.withOpacity(0.1), + border: Border.all( + color: AppColors.primaryBlue, + width: 4, + ), + ), + child: Center( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: Text( + controller.prepareDuration.toString(), + key: ValueKey(controller.prepareDuration.value), + style: TextStyle( + fontSize: 48, + fontWeight: FontWeight.bold, + color: AppColors.primaryBlue, + ), + ), + ), + ), + ), + ); + }, + ), + const SizedBox(height: 32), + Text( + context.tr('get_ready'), + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + const SizedBox(height: 16), + Text( + context.tr('quiz_starting_soon'), + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + ), + ), + ], + ), + ); + } + + Widget _buildCustomAppBar(BuildContext context) { + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + onPressed: () => Get.back(), + icon: const Icon(Icons.arrow_back_ios, color: Colors.black87), + ), Text( context.tr('quiz_play_title'), - style: TextStyle( + style: const TextStyle( color: Colors.black, fontWeight: FontWeight.bold, fontSize: 20, ), ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: AppColors.primaryBlue.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + children: [ + Icon( + Icons.timer_outlined, + size: 16, + color: AppColors.primaryBlue, + ), + const SizedBox(width: 4), + Obx(() => Text( + '${controller.timeLeft.value}s', + style: TextStyle( + color: AppColors.primaryBlue, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + )), + ], + ), + ), ], ), ); @@ -71,38 +166,141 @@ class QuizPlayView extends GetView { Widget _buildProgressBar() { final question = controller.currentQuestion; - return LinearProgressIndicator( - value: controller.timeLeft.value / question.duration, - minHeight: 8, - backgroundColor: Colors.grey[300], - valueColor: const AlwaysStoppedAnimation(Color(0xFF2563EB)), + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Progress', + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + fontWeight: FontWeight.w500, + ), + ), + Text( + '${((controller.currentIndex.value + 1) / controller.quizData.questionListings.length * 100).toInt()}%', + style: TextStyle( + fontSize: 14, + color: AppColors.primaryBlue, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 8), + AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: 8, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: Colors.grey[300], + ), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: (controller.currentIndex.value + 1) / controller.quizData.questionListings.length, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + gradient: LinearGradient( + colors: [AppColors.primaryBlue, AppColors.primaryBlue.withOpacity(0.7)], + ), + ), + ), + ), + ), + const SizedBox(height: 12), + // Time progress bar + Obx(() => AnimatedContainer( + duration: const Duration(milliseconds: 100), + height: 4, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + color: Colors.grey[300], + ), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: controller.timeLeft.value / question.duration, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + color: controller.timeLeft.value > question.duration * 0.3 + ? Colors.green + : controller.timeLeft.value > question.duration * 0.1 + ? Colors.orange + : Colors.red, + ), + ), + ), + )), + ], ); } Widget _buildQuestionIndicator(BuildContext context) { - return Text( - context.tr( - 'question_indicator', - namedArgs: { - 'current': (controller.currentIndex.value + 1).toString(), - 'total': controller.quizData.questionListings.length.toString(), - }, - ), - style: const TextStyle( - fontSize: 16, - color: Colors.grey, - fontWeight: FontWeight.w500, + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: Container( + key: ValueKey(controller.currentIndex.value), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: AppColors.primaryBlue.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + context.tr( + 'question_indicator', + namedArgs: { + 'current': (controller.currentIndex.value + 1).toString(), + 'total': controller.quizData.questionListings.length.toString(), + }, + ), + style: TextStyle( + fontSize: 14, + color: AppColors.primaryBlue, + fontWeight: FontWeight.bold, + ), + ), ), ); } Widget _buildQuestionText() { - return Text( - controller.currentQuestion.question, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Colors.black, + return AnimatedSwitcher( + duration: const Duration(milliseconds: 400), + transitionBuilder: (Widget child, Animation animation) { + return SlideTransition( + position: Tween( + begin: const Offset(0.3, 0), + end: Offset.zero, + ).animate(animation), + child: FadeTransition(opacity: animation, child: child), + ); + }, + child: Container( + key: ValueKey(controller.currentQuestion.question), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: Text( + controller.currentQuestion.question, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.black87, + height: 1.4, + ), + ), ), ); } @@ -111,56 +309,186 @@ class QuizPlayView extends GetView { final question = controller.currentQuestion; if (question is OptionQuestion) { - return Column( - children: List.generate(question.options.length, (index) { - final option = question.options[index]; - final isSelected = controller.idxOptionSelected.value == index; - - return Container( - margin: const EdgeInsets.only(bottom: 12), - width: double.infinity, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: isSelected ? AppColors.primaryBlue : Colors.white, - foregroundColor: isSelected ? Colors.white : Colors.black, - side: const BorderSide(color: Colors.grey), - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - onPressed: () => controller.selectAnswerOption(index), - child: Text(option), - ), + return AnimatedList( + initialItemCount: question.options.length, + itemBuilder: (context, index, animation) { + return SlideTransition( + position: Tween( + begin: const Offset(1, 0), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: animation, + curve: Interval(index * 0.1, 1.0, curve: Curves.easeOut), + )), + child: _buildOptionButton(question.options[index], index), ); - }), + }, ); } else if (question.type == 'true_false') { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + return Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - _buildTrueFalseButton(context.tr('yes'), true, controller.choosenAnswerTOF), - _buildTrueFalseButton(context.tr('no'), false, controller.choosenAnswerTOF), + Row( + children: [ + Expanded( + child: _buildTrueFalseButton( + context.tr('yes'), + true, + controller.choosenAnswerTOF, + Icons.check_circle, + Colors.green, + ), + ), + const SizedBox(width: 16), + Expanded( + child: _buildTrueFalseButton( + context.tr('no'), + false, + controller.choosenAnswerTOF, + Icons.cancel, + Colors.red, + ), + ), + ], + ), ], ); } else { - return GlobalTextField(controller: controller.answerTextController); + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: GlobalTextField(controller: controller.answerTextController), + ); } } - Widget _buildTrueFalseButton(String label, bool value, RxInt choosenAnswer) { + Widget _buildOptionButton(String option, int index) { + return Obx(() { + final isSelected = controller.idxOptionSelected.value == index; + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 12), + width: double.infinity, + child: Material( + elevation: isSelected ? 8 : 2, + borderRadius: BorderRadius.circular(16), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () { + controller.selectAnswerOption(index); + // Add haptic feedback + // HapticFeedback.lightImpact(); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 20), + decoration: BoxDecoration( + color: isSelected ? AppColors.primaryBlue : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected ? AppColors.primaryBlue : Colors.grey.shade300, + width: isSelected ? 2 : 1, + ), + ), + child: Row( + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 24, + height: 24, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected ? Colors.white : Colors.transparent, + border: Border.all( + color: isSelected ? Colors.white : Colors.grey, + width: 2, + ), + ), + child: isSelected ? const Icon(Icons.check, size: 16, color: AppColors.primaryBlue) : null, + ), + const SizedBox(width: 16), + Expanded( + child: Text( + option, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: isSelected ? Colors.white : Colors.black87, + ), + ), + ), + ], + ), + ), + ), + ), + ); + }); + } + + Widget _buildTrueFalseButton(String label, bool value, RxInt choosenAnswer, IconData icon, Color color) { return Obx(() { final isSelected = (choosenAnswer.value == (value ? 1 : 2)); - return ElevatedButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: isSelected ? (value ? Colors.green[100] : Colors.red[100]) : Colors.white, - foregroundColor: Colors.black, - side: const BorderSide(color: Colors.grey), - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + height: 120, + child: Material( + elevation: isSelected ? 8 : 2, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () => controller.onChooseTOF(value), + child: Container( + decoration: BoxDecoration( + color: isSelected ? color.withOpacity(0.1) : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isSelected ? color : Colors.grey.shade300, + width: isSelected ? 3 : 1, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected ? color : Colors.grey.shade100, + ), + child: Icon( + icon, + size: 32, + color: isSelected ? Colors.white : Colors.grey, + ), + ), + const SizedBox(height: 12), + Text( + label, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: isSelected ? color : Colors.black87, + ), + ), + ], + ), + ), + ), ), - onPressed: () => controller.onChooseTOF(value), - icon: Icon(value ? Icons.check_circle_outline : Icons.cancel_outlined), - label: Text(label), ); }); } @@ -169,17 +497,50 @@ class QuizPlayView extends GetView { return Obx(() { final isEnabled = controller.isAnswerSelected.value; - return ElevatedButton( - onPressed: isEnabled ? controller.nextQuestion : null, - style: ElevatedButton.styleFrom( - backgroundColor: isEnabled ? const Color(0xFF2563EB) : Colors.grey, - foregroundColor: Colors.white, - minimumSize: const Size(double.infinity, 50), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - child: Text( - context.tr('next'), - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + margin: const EdgeInsets.only(top: 20), + child: Material( + elevation: isEnabled ? 6 : 2, + borderRadius: BorderRadius.circular(16), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: isEnabled ? controller.nextQuestion : null, + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + width: double.infinity, + height: 56, + decoration: BoxDecoration( + gradient: isEnabled + ? LinearGradient( + colors: [AppColors.primaryBlue, AppColors.primaryBlue.withOpacity(0.8)], + ) + : null, + color: !isEnabled ? Colors.grey.shade300 : null, + borderRadius: BorderRadius.circular(16), + ), + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + context.tr('next'), + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: isEnabled ? Colors.white : Colors.grey, + ), + ), + const SizedBox(width: 8), + Icon( + Icons.arrow_forward, + color: isEnabled ? Colors.white : Colors.grey, + ), + ], + ), + ), + ), + ), ), ); }); From c2d838d17d7da531433c51b2befd9183a567935c Mon Sep 17 00:00:00 2001 From: akhdanre Date: Fri, 23 May 2025 19:20:45 +0700 Subject: [PATCH 080/104] feat: adjust the loading --- .../quiz_play/view/quiz_play_view.dart | 44 +++++++++---------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/lib/feature/quiz_play/view/quiz_play_view.dart b/lib/feature/quiz_play/view/quiz_play_view.dart index 73e538f..d12c9ed 100644 --- a/lib/feature/quiz_play/view/quiz_play_view.dart +++ b/lib/feature/quiz_play/view/quiz_play_view.dart @@ -21,8 +21,7 @@ class QuizPlayView extends GetView { return _buildCountdownScreen(context); } - return Column( - crossAxisAlignment: CrossAxisAlignment.start, + return ListView( children: [ _buildCustomAppBar(context), const SizedBox(height: 20), @@ -58,7 +57,7 @@ class QuizPlayView extends GetView { height: 120, decoration: BoxDecoration( shape: BoxShape.circle, - color: AppColors.primaryBlue.withOpacity(0.1), + color: AppColors.primaryBlue.withValues(alpha: 0.1), border: Border.all( color: AppColors.primaryBlue, width: 4, @@ -67,15 +66,15 @@ class QuizPlayView extends GetView { child: Center( child: AnimatedSwitcher( duration: const Duration(milliseconds: 300), - child: Text( - controller.prepareDuration.toString(), - key: ValueKey(controller.prepareDuration.value), - style: TextStyle( - fontSize: 48, - fontWeight: FontWeight.bold, - color: AppColors.primaryBlue, - ), - ), + child: Obx(() => Text( + controller.prepareDuration.toString(), + key: ValueKey(controller.prepareDuration.value), + style: TextStyle( + fontSize: 48, + fontWeight: FontWeight.bold, + color: AppColors.primaryBlue, + ), + )), ), ), ), @@ -113,7 +112,7 @@ class QuizPlayView extends GetView { borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.05), + color: Colors.black.withValues(alpha: 0.05), blurRadius: 10, offset: const Offset(0, 2), ), @@ -122,10 +121,7 @@ class QuizPlayView extends GetView { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - IconButton( - onPressed: () => Get.back(), - icon: const Icon(Icons.arrow_back_ios, color: Colors.black87), - ), + const SizedBox(width: 48), // Placeholder for balance Text( context.tr('quiz_play_title'), style: const TextStyle( @@ -137,7 +133,7 @@ class QuizPlayView extends GetView { Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( - color: AppColors.primaryBlue.withOpacity(0.1), + color: AppColors.primaryBlue.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(20), ), child: Row( @@ -204,7 +200,7 @@ class QuizPlayView extends GetView { decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), gradient: LinearGradient( - colors: [AppColors.primaryBlue, AppColors.primaryBlue.withOpacity(0.7)], + colors: [AppColors.primaryBlue, AppColors.primaryBlue.withValues(alpha: 0.7)], ), ), ), @@ -245,7 +241,7 @@ class QuizPlayView extends GetView { key: ValueKey(controller.currentIndex.value), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( - color: AppColors.primaryBlue.withOpacity(0.1), + color: AppColors.primaryBlue.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(20), ), child: Text( @@ -286,7 +282,7 @@ class QuizPlayView extends GetView { borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.05), + color: Colors.black.withValues(alpha: 0.05), blurRadius: 15, offset: const Offset(0, 5), ), @@ -361,7 +357,7 @@ class QuizPlayView extends GetView { borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.05), + color: Colors.black.withValues(alpha: 0.05), blurRadius: 15, offset: const Offset(0, 5), ), @@ -452,7 +448,7 @@ class QuizPlayView extends GetView { onTap: () => controller.onChooseTOF(value), child: Container( decoration: BoxDecoration( - color: isSelected ? color.withOpacity(0.1) : Colors.white, + color: isSelected ? color.withValues(alpha: 0.1) : Colors.white, borderRadius: BorderRadius.circular(20), border: Border.all( color: isSelected ? color : Colors.grey.shade300, @@ -513,7 +509,7 @@ class QuizPlayView extends GetView { decoration: BoxDecoration( gradient: isEnabled ? LinearGradient( - colors: [AppColors.primaryBlue, AppColors.primaryBlue.withOpacity(0.8)], + colors: [AppColors.primaryBlue, AppColors.primaryBlue.withValues(alpha: 0.8)], ) : null, color: !isEnabled ? Colors.grey.shade300 : null, From 20017d5bf1e5584e84e97ed939e8e4ff7326b372 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Fri, 23 May 2025 19:30:46 +0700 Subject: [PATCH 081/104] feat: adjustment on the interface play quiz multiplayer --- .../view/play_quiz_multiplayer.dart | 739 +++++++++++++----- 1 file changed, 561 insertions(+), 178 deletions(-) diff --git a/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart b/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart index 6981b05..f685ff3 100644 --- a/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart +++ b/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart @@ -17,7 +17,7 @@ class PlayQuizMultiplayerView extends GetView { } if (controller.currentQuestion.value == null) { - return const Center(child: CircularProgressIndicator()); + return _buildLoadingView(); } return _buildQuestionView(); @@ -26,133 +26,340 @@ class PlayQuizMultiplayerView extends GetView { ); } - Widget _buildQuestionView() { - final question = controller.currentQuestion.value!; - return SafeArea( + Widget _buildLoadingView() { + return Center( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, children: [ - // Custom AppBar content moved to body - Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - "Soal ${(question.questionIndex)}/10", - style: const TextStyle( - color: Colors.black, - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - ), - - Obx(() { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Time remaining text - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - "Waktu tersisa:", - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Colors.black54, - ), - ), - Text( - "${controller.remainingTime.value} detik", - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: controller.remainingTime.value <= 10 ? Colors.red : const Color(0xFF2563EB), - ), - ), - ], - ), - const SizedBox(height: 8), - // Progress bar - LinearProgressIndicator( - value: controller.remainingTime.value / question.duration, - minHeight: 8, - backgroundColor: Colors.grey[300], - valueColor: AlwaysStoppedAnimation( - controller.remainingTime.value <= 10 ? Colors.red : const Color(0xFF2563EB), + TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), + duration: const Duration(milliseconds: 1500), + builder: (context, value, child) { + return Transform.rotate( + angle: value * 6.28, + child: Container( + width: 60, + height: 60, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: const Color(0xFF2563EB), + width: 4, ), - borderRadius: BorderRadius.circular(4), ), - ], - ), - ); - }), - - const SizedBox(height: 20), - Obx(() { - if (controller.isASentAns.value) { - return Container( - padding: const EdgeInsets.all(20), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Add a nice loading animation - - const SizedBox(height: 24), - // Improved text with better styling - const Text( - "Jawaban Anda telah terkirim", - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.blue, - ), - ), - const SizedBox(height: 8), - // Informative subtext - const Text( - "Mohon tunggu soal selanjutnya", - style: TextStyle( - fontSize: 14, - color: Colors.grey, - ), - ), - ], + child: const Center( + child: Icon( + Icons.quiz, + color: Color(0xFF2563EB), + size: 30, + ), ), ), ); - } + }, + ), + const SizedBox(height: 20), + const Text( + "Memuat soal...", + style: TextStyle( + fontSize: 16, + color: Colors.black54, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } - return Expanded( - child: Padding( - padding: const EdgeInsets.all(20.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - question.question, - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black), - ), - const SizedBox(height: 20), - if (question.type == 'option') _buildOptionQuestion(), - if (question.type == 'fill_the_blank') _buildFillInBlankQuestion(), - if (question.type == 'true_false') _buildTrueFalseQuestion(), - const Spacer(), - Obx( - () => GlobalButton( - text: "Kirim jawaban", - onPressed: controller.submitAnswer, - type: controller.buttonType.value, - ), - ) - ], + Widget _buildQuestionView() { + final question = controller.currentQuestion.value!; + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCustomAppBar(question), + const SizedBox(height: 20), + _buildProgressSection(question), + const SizedBox(height: 20), + _buildQuestionCard(question), + const SizedBox(height: 30), + Expanded(child: _buildAnswerSection()), + _buildSubmitButton(), + ], + ), + ), + ); + } + + Widget _buildCustomAppBar(dynamic question) { + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFF2563EB).withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + "Soal ${question.questionIndex}/10", + style: const TextStyle( + color: Color(0xFF2563EB), + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + Row( + children: [ + Icon( + Icons.people, + size: 20, + color: const Color(0xFF2563EB), + ), + const SizedBox(width: 4), + Text( + "Multiplayer", + style: TextStyle( + color: const Color(0xFF2563EB), + fontWeight: FontWeight.w600, + fontSize: 14, ), ), - ); - }), - // Question content + ], + ), + Obx(() => Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: controller.remainingTime.value <= 10 ? Colors.red.withOpacity(0.1) : const Color(0xFF2563EB).withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + children: [ + Icon( + Icons.timer_outlined, + size: 16, + color: controller.remainingTime.value <= 10 ? Colors.red : const Color(0xFF2563EB), + ), + const SizedBox(width: 4), + Text( + "${controller.remainingTime.value}s", + style: TextStyle( + color: controller.remainingTime.value <= 10 ? Colors.red : const Color(0xFF2563EB), + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ], + ), + )), + ], + ), + ); + } + + Widget _buildProgressSection(dynamic question) { + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Progress", + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + fontWeight: FontWeight.w500, + ), + ), + Text( + "${(question.questionIndex * 10).toInt()}%", + style: const TextStyle( + fontSize: 14, + color: Color(0xFF2563EB), + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 8), + AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: 8, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: Colors.grey[300], + ), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: question.questionIndex / 10, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + gradient: const LinearGradient( + colors: [Color(0xFF2563EB), Color(0xFF1E40AF)], + ), + ), + ), + ), + ), + const SizedBox(height: 12), + Obx(() => AnimatedContainer( + duration: const Duration(milliseconds: 100), + height: 4, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + color: Colors.grey[300], + ), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: controller.remainingTime.value / question.duration, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + color: controller.remainingTime.value > question.duration * 0.3 + ? Colors.green + : controller.remainingTime.value > question.duration * 0.1 + ? Colors.orange + : Colors.red, + ), + ), + ), + )), + ], + ); + } + + Widget _buildQuestionCard(dynamic question) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 400), + transitionBuilder: (Widget child, Animation animation) { + return SlideTransition( + position: Tween( + begin: const Offset(0.3, 0), + end: Offset.zero, + ).animate(animation), + child: FadeTransition(opacity: animation, child: child), + ); + }, + child: Container( + key: ValueKey(question.question), + padding: const EdgeInsets.all(20), + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: Text( + question.question, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.black87, + height: 1.4, + ), + ), + ), + ); + } + + Widget _buildAnswerSection() { + return Obx(() { + if (controller.isASentAns.value) { + return _buildWaitingView(); + } + + final question = controller.currentQuestion.value!; + + if (question.type == 'option') return _buildOptionQuestion(); + if (question.type == 'fill_the_blank') return _buildFillInBlankQuestion(); + if (question.type == 'true_false') return _buildTrueFalseQuestion(); + + return const SizedBox(); + }); + } + + Widget _buildWaitingView() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), + duration: const Duration(milliseconds: 2000), + builder: (context, value, child) { + return Container( + width: 80, + height: 80, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.green.withOpacity(0.1), + border: Border.all( + color: Colors.green, + width: 3, + ), + ), + child: Transform.scale( + scale: 0.8 + (value * 0.2), + child: const Icon( + Icons.check_circle, + size: 40, + color: Colors.green, + ), + ), + ); + }, + ), + const SizedBox(height: 24), + const Text( + "Jawaban Terkirim!", + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.green, + ), + ), + const SizedBox(height: 8), + Text( + "Menunggu soal selanjutnya...", + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 20), + Container( + width: 40, + height: 40, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation(Colors.grey[400]!), + ), + ), ], ), ); @@ -162,65 +369,204 @@ class PlayQuizMultiplayerView extends GetView { final options = controller.currentQuestion.value!.options; return Column( children: List.generate(options!.length, (index) { - final option = options[index]; - final isSelected = controller.selectedAnswer.value == index.toString(); - - return Container( - margin: const EdgeInsets.only(bottom: 12), - width: double.infinity, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: isSelected ? const Color(0xFF2563EB) : Colors.white, - foregroundColor: isSelected ? Colors.white : Colors.black, - side: const BorderSide(color: Colors.grey), - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - onPressed: () => controller.selectOptionAnswer(index), - child: Text(option), - ), - ); + return _buildOptionButton(options[index], index); }), ); } + Widget _buildOptionButton(String option, int index) { + return Obx(() { + final isSelected = controller.selectedAnswer.value == index.toString(); + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 12), + width: double.infinity, + child: Material( + elevation: isSelected ? 8 : 2, + borderRadius: BorderRadius.circular(16), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () => controller.selectOptionAnswer(index), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 20), + decoration: BoxDecoration( + color: isSelected ? const Color(0xFF2563EB) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected ? const Color(0xFF2563EB) : Colors.grey.shade300, + width: isSelected ? 2 : 1, + ), + ), + child: Row( + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 24, + height: 24, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected ? Colors.white : Colors.transparent, + border: Border.all( + color: isSelected ? Colors.white : Colors.grey, + width: 2, + ), + ), + child: isSelected ? const Icon(Icons.check, size: 16, color: Color(0xFF2563EB)) : null, + ), + const SizedBox(width: 16), + Expanded( + child: Text( + option, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: isSelected ? Colors.white : Colors.black87, + ), + ), + ), + ], + ), + ), + ), + ), + ); + }); + } + Widget _buildFillInBlankQuestion() { - return Column( - children: [ - GlobalTextField(controller: controller.fillInAnswerController), - const SizedBox(height: 20), - ], + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Masukkan jawaban Anda:", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.grey[700], + ), + ), + const SizedBox(height: 16), + GlobalTextField(controller: controller.fillInAnswerController), + ], + ), ); } Widget _buildTrueFalseQuestion() { return Column( children: [ - _buildTrueFalseButton('Ya', true), - _buildTrueFalseButton('Tidak', false), + Row( + children: [ + Expanded( + child: _buildTrueFalseButton( + 'Ya', + true, + Icons.check_circle, + Colors.green, + ), + ), + const SizedBox(width: 16), + Expanded( + child: _buildTrueFalseButton( + 'Tidak', + false, + Icons.cancel, + Colors.red, + ), + ), + ], + ), ], ); } - Widget _buildTrueFalseButton(String label, bool value) { - final isSelected = controller.selectedAnswer.value == value.toString(); + Widget _buildTrueFalseButton(String label, bool value, IconData icon, Color color) { + return Obx(() { + final isSelected = controller.selectedAnswer.value == value.toString(); - return Container( - margin: const EdgeInsets.only(bottom: 12), - width: double.infinity, - child: ElevatedButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: isSelected ? (value ? Colors.green : Colors.red) : Colors.white, - foregroundColor: isSelected ? Colors.white : Colors.black, - side: const BorderSide(color: Colors.grey), - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + height: 120, + child: Material( + elevation: isSelected ? 8 : 2, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () => controller.selectTrueFalseAnswer(value), + child: Container( + decoration: BoxDecoration( + color: isSelected ? color.withOpacity(0.1) : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isSelected ? color : Colors.grey.shade300, + width: isSelected ? 3 : 1, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected ? color : Colors.grey.shade100, + ), + child: Icon( + icon, + size: 32, + color: isSelected ? Colors.white : Colors.grey, + ), + ), + const SizedBox(height: 12), + Text( + label, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: isSelected ? color : Colors.black87, + ), + ), + ], + ), + ), + ), ), - onPressed: () => controller.selectTrueFalseAnswer(value), - icon: Icon(value ? Icons.check_circle_outline : Icons.cancel_outlined), - label: Text(label), - ), - ); + ); + }); + } + + Widget _buildSubmitButton() { + return Obx(() { + if (controller.isASentAns.value) { + return const SizedBox.shrink(); + } + + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + margin: const EdgeInsets.only(top: 20), + child: GlobalButton( + text: "Kirim Jawaban", + onPressed: controller.submitAnswer, + type: controller.buttonType.value, + ), + ); + }); } Widget _buildDoneView() { @@ -230,32 +576,69 @@ class PlayQuizMultiplayerView extends GetView { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon( - Icons.check_circle, - size: 80, - color: Color(0xFF2563EB), + TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), + duration: const Duration(milliseconds: 800), + builder: (context, value, child) { + return Transform.scale( + scale: value, + child: Container( + width: 120, + height: 120, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFF2563EB).withOpacity(0.1), + border: Border.all( + color: const Color(0xFF2563EB), + width: 4, + ), + ), + child: const Icon( + Icons.emoji_events, + size: 60, + color: Color(0xFF2563EB), + ), + ), + ); + }, ), - const SizedBox(height: 20), + const SizedBox(height: 32), const Text( - "Kuis telah selesai!", - style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + "Kuis Selesai!", + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), ), const SizedBox(height: 16), - const Text( - "Terima kasih telah berpartisipasi.", - style: TextStyle(fontSize: 16, color: Colors.black54), + Text( + "Terima kasih telah berpartisipasi dalam kuis multiplayer.", + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + height: 1.5, + ), textAlign: TextAlign.center, ), const SizedBox(height: 40), - ElevatedButton( - onPressed: controller.goToDetailResult, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF2563EB), - foregroundColor: Colors.white, - minimumSize: const Size(double.infinity, 50), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + Container( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + onPressed: controller.goToDetailResult, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF2563EB), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + elevation: 6, + ), + icon: const Icon(Icons.assessment), + label: const Text( + "Lihat Hasil", + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), ), - child: const Text("Lihat Hasil", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), ), ], ), From 128afe9ad6bbfcba29d6ce2bdff9b84e2cd8b7ed Mon Sep 17 00:00:00 2001 From: akhdanre Date: Fri, 23 May 2025 20:28:22 +0700 Subject: [PATCH 082/104] feat: adjustment on the interface --- lib/app/const/colors/app_colors.dart | 1 + lib/feature/history/view/history_view.dart | 126 ++-- lib/feature/navigation/views/navbar_view.dart | 4 + .../room_maker/view/room_maker_view.dart | 696 +++++++++++++++--- lib/feature/search/view/search_view.dart | 3 +- 5 files changed, 664 insertions(+), 166 deletions(-) diff --git a/lib/app/const/colors/app_colors.dart b/lib/app/const/colors/app_colors.dart index 203044e..398a4d3 100644 --- a/lib/app/const/colors/app_colors.dart +++ b/lib/app/const/colors/app_colors.dart @@ -5,6 +5,7 @@ class AppColors { static const Color darkText = Color(0xFF172B4D); static const Color softGrayText = Color(0xFF6B778C); static const Color background = Color(0xFFFAFBFC); + static const Color background2 = Color(0xFFF9FAFB); static const Color borderLight = Color(0xFFE1E4E8); static const Color accentBlue = Color(0xFFD6E4FF); diff --git a/lib/feature/history/view/history_view.dart b/lib/feature/history/view/history_view.dart index cd41db1..8e45d24 100644 --- a/lib/feature/history/view/history_view.dart +++ b/lib/feature/history/view/history_view.dart @@ -62,62 +62,102 @@ class HistoryView extends GetView { } Widget _buildHistoryCard(QuizHistory item) { + final scorePercentage = item.totalCorrect / item.totalQuestion; + final scoreColor = scorePercentage >= 0.7 ? AppColors.primaryBlue : AppColors.scorePoor; + return GestureDetector( onTap: () => controller.goToDetailHistory(item.answerId), child: Container( - margin: const EdgeInsets.only(bottom: 16), - padding: const EdgeInsets.all(16), + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(14), decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: const [ + color: AppColors.background, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.borderLight), + boxShadow: [ BoxShadow( - color: Colors.black12, - blurRadius: 4, - offset: Offset(0, 2), - ) + color: Colors.black.withValues(alpha: 0.03), + blurRadius: 8, + offset: const Offset(0, 2), + ), ], ), child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: Colors.blue.shade100, - shape: BoxShape.circle, - ), - child: const Icon(Icons.history, color: Colors.blue), - ), + _buildIconBox(scoreColor), const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(item.title, style: AppTextStyles.statValue), - const SizedBox(height: 4), - Text(item.date, style: AppTextStyles.caption), - const SizedBox(height: 8), - Row( - children: [ - const Icon(Icons.check_circle, size: 14, color: Colors.green), - const SizedBox(width: 4), - Text( - tr('score_label', namedArgs: {'correct': item.totalCorrect.toString(), 'total': item.totalQuestion.toString()}), - style: AppTextStyles.caption, - ), - const SizedBox(width: 16), - const Icon(Icons.timer, size: 14, color: Colors.grey), - const SizedBox(width: 4), - Text(tr("duration_minutes", namedArgs: {"minute": "3"}), style: AppTextStyles.caption), - ], - ), - ], - ), - ) + Expanded(child: _buildHistoryInfo(item, scorePercentage)), ], ), ), ); } + + Widget _buildIconBox(Color scoreColor) { + return Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: scoreColor, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.assignment_turned_in, + color: Colors.white, + size: 28, + ), + ); + } + + Widget _buildHistoryInfo(QuizHistory item, double scorePercentage) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.darkText, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + 'Completed on ${item.date}', + style: const TextStyle( + fontSize: 12, + color: AppColors.softGrayText, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + const Icon(Icons.check_circle_outline, size: 14, color: AppColors.softGrayText), + const SizedBox(width: 4), + Text( + '${item.totalCorrect}/${item.totalQuestion} Correct', + style: const TextStyle(fontSize: 12, color: AppColors.softGrayText), + ), + const SizedBox(width: 12), + const Icon(Icons.access_time, size: 14, color: AppColors.softGrayText), + const SizedBox(width: 4), + Text( + tr("duration_minutes", namedArgs: {"minute": "3"}), + style: const TextStyle(fontSize: 12, color: AppColors.softGrayText), + ), + const SizedBox(width: 12), + const Icon(Icons.percent, size: 14, color: AppColors.softGrayText), + const SizedBox(width: 4), + Text( + '${(scorePercentage * 100).toInt()}%', + style: const TextStyle(fontSize: 12, color: AppColors.softGrayText), + ), + ], + ), + ], + ); + } } diff --git a/lib/feature/navigation/views/navbar_view.dart b/lib/feature/navigation/views/navbar_view.dart index 2f0f781..7551ee5 100644 --- a/lib/feature/navigation/views/navbar_view.dart +++ b/lib/feature/navigation/views/navbar_view.dart @@ -1,6 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; import 'package:quiz_app/feature/history/view/history_view.dart'; import 'package:quiz_app/feature/home/view/home_page.dart'; import 'package:quiz_app/feature/library/view/library_view.dart'; @@ -14,6 +15,7 @@ class NavbarView extends GetView { @override Widget build(BuildContext context) { return Scaffold( + backgroundColor: AppColors.background, body: Obx(() { switch (controller.selectedIndex.value) { case 0: @@ -32,6 +34,8 @@ class NavbarView extends GetView { }), bottomNavigationBar: Obx( () => BottomNavigationBar( + fixedColor: AppColors.primaryBlue, + backgroundColor: AppColors.background2, type: BottomNavigationBarType.fixed, currentIndex: controller.selectedIndex.value, onTap: controller.changePage, diff --git a/lib/feature/room_maker/view/room_maker_view.dart b/lib/feature/room_maker/view/room_maker_view.dart index 2dd72b1..9c26e92 100644 --- a/lib/feature/room_maker/view/room_maker_view.dart +++ b/lib/feature/room_maker/view/room_maker_view.dart @@ -1,134 +1,348 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; -import 'package:quiz_app/app/const/text/text_style.dart'; -import 'package:quiz_app/component/global_button.dart'; import 'package:quiz_app/component/global_text_field.dart'; -import 'package:quiz_app/component/label_text_field.dart'; import 'package:quiz_app/component/quiz_container_component.dart'; import 'package:quiz_app/feature/room_maker/controller/room_maker_controller.dart'; class RoomMakerView extends GetView { + const RoomMakerView({super.key}); + @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: AppColors.background, - appBar: AppBar(title: Text("Buat Room Quiz")), - body: Padding( - padding: const EdgeInsets.all(16.0), + backgroundColor: const Color(0xFFF9FAFB), + body: SafeArea( child: Column( children: [ - LabelTextField( - label: "Room Name", - ), - GlobalTextField( - controller: controller.nameTC, - ), - SizedBox( - height: 10, - ), - LabelTextField(label: "Jumlah Maksimal Pemain"), - GlobalTextField( - controller: controller.maxPlayerTC, - textInputType: TextInputType.number, - ), - const SizedBox(height: 10), - quizMeta(), - SizedBox( - height: 10, - ), - _buildModeSelector(), - SizedBox( - height: 10, - ), + _buildCustomAppBar(context), Expanded( - child: Container( - child: Obx(() => ListView.builder( - controller: controller.scrollController, - itemCount: controller.availableQuizzes.length + (controller.isLoading ? 1 : 0), - itemBuilder: (context, index) { - if (index < controller.availableQuizzes.length) { - return QuizContainerComponent( - data: controller.availableQuizzes[index], - onTap: controller.onQuizChoosen, - ); - } else { - // Loading Indicator di Bawah - return Padding( - padding: const EdgeInsets.all(16.0), - child: Center( - child: CircularProgressIndicator(), - ), - ); - } - }, - )), + child: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildRoomSettingsCard(), + const SizedBox(height: 20), + _buildQuizMetaCard(), + const SizedBox(height: 20), + _buildModeSelector(), + const SizedBox(height: 20), + _buildQuizListSection(), + const SizedBox(height: 80), + ], + ), ), ), - SizedBox( - height: 10, - ), - GlobalButton(text: "Buat Room", onPressed: controller.onCreateRoom) ], ), ), + floatingActionButton: _buildCreateRoomButton(), + floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, + ); + } + + Widget _buildCustomAppBar(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + IconButton( + onPressed: () => Get.back(), + icon: const Icon(Icons.arrow_back_ios, color: Colors.black87), + ), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Buat Room Quiz", + style: TextStyle( + color: Colors.black, + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ), + Text( + "Siapkan room untuk bermain bersama", + style: TextStyle( + color: Colors.grey[600], + fontSize: 14, + ), + ), + ], + ), + ], + ), ); } - Widget quizMeta() { - return Obx(() { - final quiz = controller.selectedQuiz.value; - if (quiz == null) return SizedBox.shrink(); + Widget _buildRoomSettingsCard() { + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.primaryBlue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.settings, + color: AppColors.primaryBlue, + size: 24, + ), + ), + const SizedBox(width: 12), + const Text( + "Pengaturan Room", + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ], + ), + const SizedBox(height: 20), + _buildInputSection( + "Nama Room", + "Masukkan nama room", + Icons.meeting_room, + controller.nameTC, + ), + const SizedBox(height: 16), + _buildInputSection( + "Maksimal Pemain", + "Berapa banyak pemain yang bisa bergabung?", + Icons.group, + controller.maxPlayerTC, + isNumber: true, + ), + ], + ), + ); + } - return Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - margin: const EdgeInsets.only(top: 16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 8, - offset: Offset(0, 4), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + Widget _buildInputSection(String label, String hint, IconData icon, TextEditingController textController, {bool isNumber = false}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( children: [ + Icon( + icon, + size: 20, + color: AppColors.primaryBlue, + ), + const SizedBox(width: 8), Text( - "Kuis yang Dipilih", - style: TextStyle( + label, + style: const TextStyle( fontSize: 16, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w600, color: Colors.black87, ), ), - const SizedBox(height: 12), - _buildMetaRow("Judul", quiz.title), - _buildMetaRow("Deskripsi", quiz.description), - _buildMetaRow("Jumlah Soal", quiz.totalQuiz.toString()), - _buildMetaRow("Durasi", "${quiz.duration ~/ 60} menit"), ], ), + const SizedBox(height: 8), + Text( + hint, + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 12), + GlobalTextField( + controller: textController, + textInputType: isNumber ? TextInputType.number : TextInputType.text, + ), + ], + ); + } + + Widget _buildQuizMetaCard() { + return Obx(() { + final quiz = controller.selectedQuiz.value; + + return AnimatedContainer( + duration: const Duration(milliseconds: 400), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: quiz == null ? Colors.grey[50] : Colors.white, + borderRadius: BorderRadius.circular(16), + border: quiz == null ? Border.all(color: Colors.grey[300]!, style: BorderStyle.solid, width: 2) : null, + boxShadow: quiz == null + ? null + : [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: quiz == null ? _buildNoQuizSelected() : _buildSelectedQuizInfo(quiz), ); }); } + Widget _buildNoQuizSelected() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey[200], + shape: BoxShape.circle, + ), + child: Icon( + Icons.quiz_outlined, + size: 32, + color: Colors.grey[500], + ), + ), + const SizedBox(height: 12), + Text( + "Pilih kuis untuk dimainkan", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.grey[600], + ), + ), + Text( + "Scroll ke bawah untuk memilih kuis", + style: TextStyle( + fontSize: 14, + color: Colors.grey[500], + ), + ), + ], + ), + ); + } + + Widget _buildSelectedQuizInfo(dynamic quiz) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.check_circle, + color: Colors.green, + size: 24, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Kuis Terpilih", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.green, + ), + ), + Text( + "Siap untuk dimainkan!", + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.primaryBlue.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppColors.primaryBlue.withValues(alpha: 0.2), + ), + ), + child: Column( + children: [ + _buildMetaRow("Judul", quiz.title), + _buildMetaRow("Deskripsi", quiz.description), + _buildMetaRow("Jumlah Soal", "${quiz.totalQuiz} soal"), + _buildMetaRow("Durasi", "${quiz.duration ~/ 60} menit"), + ], + ), + ), + ], + ); + } + Widget _buildMetaRow(String label, String value) { return Padding( - padding: const EdgeInsets.only(bottom: 8.0), + padding: const EdgeInsets.only(bottom: 12.0), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text("$label: ", style: AppTextStyles.subtitle), + Text( + label, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + const SizedBox(width: 8), Expanded( child: Text( value, - style: AppTextStyles.subtitle, + style: TextStyle( + fontSize: 14, + color: Colors.grey[700], + ), overflow: TextOverflow.ellipsis, + maxLines: 2, ), ), ], @@ -137,51 +351,289 @@ class RoomMakerView extends GetView { } Widget _buildModeSelector() { - return Container( - decoration: BoxDecoration( - color: AppColors.background, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.borderLight), - ), - child: Row( + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.source, + size: 20, + color: AppColors.primaryBlue, + ), + const SizedBox(width: 8), + const Text( + "Sumber Kuis", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ], + ), + const SizedBox(height: 12), + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + _buildModeButton('Kuisku', controller.isOnwQuiz, true, Icons.person), + _buildModeButton('Rekomendasi', controller.isOnwQuiz, false, Icons.recommend), + ], + ), + ), + ], + ); + } + + Widget _buildModeButton(String label, RxBool isSelected, bool base, IconData icon) { + return Expanded( + child: Obx(() { + final selected = isSelected.value == base; + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: base + ? const BorderRadius.only( + topLeft: Radius.circular(16), + bottomLeft: Radius.circular(16), + ) + : const BorderRadius.only( + topRight: Radius.circular(16), + bottomRight: Radius.circular(16), + ), + onTap: () => controller.onQuizSourceChange(base), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12), + decoration: BoxDecoration( + color: selected ? AppColors.primaryBlue : Colors.transparent, + borderRadius: base + ? const BorderRadius.only( + topLeft: Radius.circular(16), + bottomLeft: Radius.circular(16), + ) + : const BorderRadius.only( + topRight: Radius.circular(16), + bottomRight: Radius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 20, + color: selected ? Colors.white : Colors.grey[600], + ), + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + color: selected ? Colors.white : Colors.grey[600], + fontWeight: FontWeight.w600, + fontSize: 16, + ), + ), + ], + ), + ), + ), + ), + ); + }), + ); + } + + Widget _buildQuizListSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.list, + size: 20, + color: AppColors.primaryBlue, + ), + const SizedBox(width: 8), + const Text( + "Pilih Kuis", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ], + ), + const SizedBox(height: 12), + Container( + height: 400, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: Obx(() => controller.availableQuizzes.isEmpty && !controller.isLoading + ? _buildEmptyQuizList() + : ListView.builder( + controller: controller.scrollController, + padding: const EdgeInsets.all(16), + itemCount: controller.availableQuizzes.length + (controller.isLoading ? 1 : 0), + itemBuilder: (context, index) { + if (index < controller.availableQuizzes.length) { + return AnimatedContainer( + duration: Duration(milliseconds: 200 + (index * 50)), + margin: const EdgeInsets.only(bottom: 12), + child: QuizContainerComponent( + data: controller.availableQuizzes[index], + onTap: controller.onQuizChoosen, + ), + ); + } else { + return _buildLoadingIndicator(); + } + }, + )), + ), + ], + ); + } + + Widget _buildEmptyQuizList() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - _buildModeButton('kuismu', controller.isOnwQuiz, true), - _buildModeButton('Rekomendasi', controller.isOnwQuiz, false), + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.grey[100], + shape: BoxShape.circle, + ), + child: Icon( + Icons.quiz_outlined, + size: 48, + color: Colors.grey[400], + ), + ), + const SizedBox(height: 16), + Text( + "Belum ada kuis tersedia", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 8), + Text( + "Coba ganti sumber kuis atau buat kuis baru", + style: TextStyle( + fontSize: 14, + color: Colors.grey[500], + ), + textAlign: TextAlign.center, + ), ], ), ); } - Widget _buildModeButton(String label, RxBool isSelected, bool base) { - return Expanded( - child: InkWell( - onTap: () => controller.onQuizSourceChange(base), - child: Obx( - () => Container( - padding: const EdgeInsets.symmetric(vertical: 14), - decoration: BoxDecoration( - color: isSelected.value == base ? AppColors.primaryBlue : Colors.transparent, - borderRadius: base - ? BorderRadius.only( - topLeft: Radius.circular(10), - bottomLeft: Radius.circular(10), - ) - : BorderRadius.only( - topRight: Radius.circular(10), - bottomRight: Radius.circular(10), - ), - ), - alignment: Alignment.center, - child: Text( - label, - style: TextStyle( - color: isSelected.value == base ? Colors.white : AppColors.softGrayText, - fontWeight: FontWeight.w600, + Widget _buildLoadingIndicator() { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(AppColors.primaryBlue), ), ), - ), + const SizedBox(width: 12), + Text( + "Memuat kuis lainnya...", + style: TextStyle( + color: Colors.grey[600], + fontSize: 14, + ), + ), + ], ), ), ); } + + Widget _buildCreateRoomButton() { + return Obx(() { + final canCreate = controller.selectedQuiz.value != null && controller.nameTC.text.isNotEmpty && controller.maxPlayerTC.text.isNotEmpty; + + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + width: MediaQuery.of(Get.context!).size.width - 32, + height: 56, + child: Material( + elevation: canCreate ? 8 : 2, + borderRadius: BorderRadius.circular(16), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: canCreate ? controller.onCreateRoom : null, + child: Container( + decoration: BoxDecoration( + gradient: canCreate + ? LinearGradient( + colors: [AppColors.primaryBlue, AppColors.primaryBlue.withValues(alpha: 0.8)], + ) + : null, + color: !canCreate ? Colors.grey[300] : null, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.add_circle, + color: canCreate ? Colors.white : Colors.grey[500], + size: 24, + ), + const SizedBox(width: 12), + Text( + "Buat Room Sekarang", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: canCreate ? Colors.white : Colors.grey[500], + ), + ), + ], + ), + ), + ), + ), + ); + }); + } } diff --git a/lib/feature/search/view/search_view.dart b/lib/feature/search/view/search_view.dart index ba4856d..4252442 100644 --- a/lib/feature/search/view/search_view.dart +++ b/lib/feature/search/view/search_view.dart @@ -1,6 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; import 'package:quiz_app/app/const/enums/listing_type.dart'; import 'package:quiz_app/component/quiz_container_component.dart'; import 'package:quiz_app/component/widget/recomendation_component.dart'; @@ -13,7 +14,7 @@ class SearchView extends GetView { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: const Color(0xFFF8F9FB), + backgroundColor: AppColors.background2, body: SafeArea( child: Padding( padding: const EdgeInsets.all(16), From 7d3f94dee17b03c5bc964db74b8f1a2f94b3b68c Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sat, 24 May 2025 22:10:43 +0700 Subject: [PATCH 083/104] feat: adjust the quiz preview --- lib/component/quiz_container_component.dart | 4 +++- lib/component/widget/quiz_item_wa_component.dart | 8 +++++++- .../quiz_preview/controller/quiz_preview_controller.dart | 8 ++++---- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/lib/component/quiz_container_component.dart b/lib/component/quiz_container_component.dart index 4e8c644..d9fd6c6 100644 --- a/lib/component/quiz_container_component.dart +++ b/lib/component/quiz_container_component.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; @@ -18,6 +19,7 @@ class QuizContainerComponent extends StatelessWidget { onTap: () => onTap(data.quizId), child: Container( padding: const EdgeInsets.all(14), + margin: EdgeInsets.symmetric(vertical: 5), decoration: BoxDecoration( color: AppColors.background, borderRadius: BorderRadius.circular(12), @@ -50,7 +52,7 @@ class QuizContainerComponent extends StatelessWidget { color: const Color(0xFF0052CC), borderRadius: BorderRadius.circular(8), ), - child: const Icon(Icons.school, color: Colors.white, size: 28), + child: const Icon(LucideIcons.box, color: Colors.white, size: 28), ); } diff --git a/lib/component/widget/quiz_item_wa_component.dart b/lib/component/widget/quiz_item_wa_component.dart index ea7e79a..c650fe3 100644 --- a/lib/component/widget/quiz_item_wa_component.dart +++ b/lib/component/widget/quiz_item_wa_component.dart @@ -113,8 +113,14 @@ class QuizItemWAComponent extends StatelessWidget { Widget _buildAnswerIndicator(BuildContext context) { final icon = isCorrect ? LucideIcons.checkCircle2 : LucideIcons.xCircle; final color = isCorrect ? AppColors.primaryBlue : Colors.red; + final String userAnswerText; + + if (userAnswer == null || userAnswer == -1) { + userAnswerText = "Tidak Menjawab"; + } else { + userAnswerText = isOptionType ? options![userAnswer] : userAnswer.toString(); + } - final String userAnswerText = isOptionType ? options![userAnswer] : userAnswer.toString(); final String correctAnswerText = targetAnswer.toString(); return Column( diff --git a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart index c6e5336..177caf6 100644 --- a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart +++ b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart @@ -122,7 +122,7 @@ class QuizPreviewController extends GetxController { logC.e(e); } finally { isLoading.value = false; - CustomFloatingLoading.hideLoadingDialog(Get.context!); + // CustomFloatingLoading.hideLoadingDialog(Get.context!); } } @@ -132,7 +132,7 @@ class QuizPreviewController extends GetxController { final q = entry.value; String typeString; - String answer = ""; + dynamic answer; List? option; switch (q.type) { @@ -142,12 +142,12 @@ class QuizPreviewController extends GetxController { break; case QuestionType.option: typeString = 'option'; - answer = q.correctAnswerIndex.toString(); + answer = q.correctAnswerIndex; option = q.options?.map((o) => o.text).toList(); break; case QuestionType.trueOrFalse: typeString = 'true_false'; - answer = q.answer ?? ""; + answer = q.answer!.contains("true"); break; default: typeString = 'fill_the_blank'; From 1e45cc271b2cd7f462b855e44a248b528a8467b4 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 25 May 2025 13:01:22 +0700 Subject: [PATCH 084/104] feat: adding new profile interface --- assets/translations/en-US.json | 20 +- assets/translations/id-ID.json | 20 +- assets/translations/ms-MY.json | 20 +- lib/core/endpoint/api_endpoint.dart | 3 +- lib/data/models/user/user_stat_model.dart | 29 ++ lib/data/services/user_service.dart | 20 ++ lib/data/services/user_storage_service.dart | 35 +- .../profile/binding/profile_binding.dart | 10 +- .../controller/profile_controller.dart | 135 ++++++- .../view/components/info_row_card.dart | 34 ++ .../view/components/section_header_card.dart | 35 ++ lib/feature/profile/view/profile_view.dart | 330 ++++++++++++------ pubspec.lock | 64 ++++ pubspec.yaml | 1 + 14 files changed, 631 insertions(+), 125 deletions(-) create mode 100644 lib/data/models/user/user_stat_model.dart create mode 100644 lib/feature/profile/view/components/info_row_card.dart create mode 100644 lib/feature/profile/view/components/section_header_card.dart diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index 06591f6..c41fea5 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -87,5 +87,23 @@ "auto_generate_quiz": "Auto Generate Quiz", "ready_to_compete": "Ready to Compete?", "enter_code_to_join": "Enter the quiz code and show your skills!", - "join_quiz_now": "Join Quiz Now" + "join_quiz_now": "Join Quiz Now", + + "total_solve": "Total Solved", + "personal_info": "Personal Information", + "phone": "Phone Number", + "location": "Location", + "joined": "Joined", + "education": "Education", + "not_set": "Not Set", + "not_available": "Not Available", + "settings": "Settings", + "legal_and_support": "Legal & Support", + "privacy_policy": "Privacy Policy", + "terms_of_service": "Terms of Service", + "help_center": "Help Center", + "contact_us": "Contact Us", + "about_app": "About App", + "version": "Version", + "close": "Close" } diff --git a/assets/translations/id-ID.json b/assets/translations/id-ID.json index 8e02cf2..75b6794 100644 --- a/assets/translations/id-ID.json +++ b/assets/translations/id-ID.json @@ -87,5 +87,23 @@ "ready_to_compete": "Siap untuk Bertanding?", "enter_code_to_join": "Masukkan kode kuis dan tunjukkan kemampuanmu!", - "join_quiz_now": "Gabung Kuis Sekarang" + "join_quiz_now": "Gabung Kuis Sekarang", + + "total_solve": "Total Diselesaikan", + "personal_info": "Informasi Pribadi", + "phone": "Nomor Telepon", + "location": "Lokasi", + "joined": "Bergabung", + "education": "Pendidikan", + "not_set": "Belum Diatur", + "not_available": "Tidak Tersedia", + "settings": "Pengaturan", + "legal_and_support": "Legal & Bantuan", + "privacy_policy": "Kebijakan Privasi", + "terms_of_service": "Syarat dan Ketentuan", + "help_center": "Pusat Bantuan", + "contact_us": "Hubungi Kami", + "about_app": "Tentang Aplikasi", + "version": "Versi", + "close": "Tutup" } diff --git a/assets/translations/ms-MY.json b/assets/translations/ms-MY.json index 32eb62f..4f2558f 100644 --- a/assets/translations/ms-MY.json +++ b/assets/translations/ms-MY.json @@ -84,5 +84,23 @@ "auto_generate_quiz": "Jana Kuiz Automatik", "ready_to_compete": "Bersedia untuk Bertanding?", "enter_code_to_join": "Masukkan kod kuiz dan tunjukkan kemahiran anda!", - "join_quiz_now": "Sertai Kuiz Sekarang" + "join_quiz_now": "Sertai Kuiz Sekarang", + + "total_solve": "Jumlah Diselesaikan", + "personal_info": "Maklumat Peribadi", + "phone": "Nombor Telefon", + "location": "Lokasi", + "joined": "Tarikh Sertai", + "education": "Pendidikan", + "not_set": "Belum Ditentukan", + "not_available": "Tidak Tersedia", + "settings": "Tetapan", + "legal_and_support": "Perundangan & Sokongan", + "privacy_policy": "Dasar Privasi", + "terms_of_service": "Terma Perkhidmatan", + "help_center": "Pusat Bantuan", + "contact_us": "Hubungi Kami", + "about_app": "Mengenai Aplikasi", + "version": "Versi", + "close": "Tutup" } diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart index 0b2528c..2b38099 100644 --- a/lib/core/endpoint/api_endpoint.dart +++ b/lib/core/endpoint/api_endpoint.dart @@ -1,5 +1,5 @@ class APIEndpoint { - static const String baseUrl = "http://192.168.1.9:5000"; + static const String baseUrl = "http://192.168.110.43:5000"; // static const String baseUrl = "http://103.193.178.121:5000"; static const String api = "$baseUrl/api"; @@ -28,4 +28,5 @@ class APIEndpoint { static const String userData = "/user"; static const String userUpdate = "/user/update"; + static const String userStat = "/user/status"; } diff --git a/lib/data/models/user/user_stat_model.dart b/lib/data/models/user/user_stat_model.dart new file mode 100644 index 0000000..769a73f --- /dev/null +++ b/lib/data/models/user/user_stat_model.dart @@ -0,0 +1,29 @@ +class UserStatModel { + final double avgScore; + final int totalSolve; + final int totalQuiz; + + UserStatModel({ + required this.avgScore, + required this.totalSolve, + required this.totalQuiz, + }); + + // Factory constructor to create an instance from JSON + factory UserStatModel.fromJson(Map json) { + return UserStatModel( + avgScore: (json['avg_score'] as num).toDouble(), + totalSolve: json['total_solve'] as int, + totalQuiz: json['total_quiz'] as int, + ); + } + + // Convert instance to JSON + Map toJson() { + return { + 'avg_score': avgScore, + 'total_solve': totalSolve, + 'total_quiz': totalQuiz, + }; + } +} diff --git a/lib/data/services/user_service.dart b/lib/data/services/user_service.dart index 6c5e9e3..bd90103 100644 --- a/lib/data/services/user_service.dart +++ b/lib/data/services/user_service.dart @@ -4,6 +4,7 @@ import 'package:quiz_app/core/endpoint/api_endpoint.dart'; import 'package:quiz_app/core/utils/logger.dart'; import 'package:quiz_app/data/models/base/base_model.dart'; import 'package:quiz_app/data/models/user/user_full_model.dart'; +import 'package:quiz_app/data/models/user/user_stat_model.dart'; import 'package:quiz_app/data/providers/dio_client.dart'; class UserService extends GetxService { @@ -54,4 +55,23 @@ class UserService extends GetxService { return null; } } + + Future?> getUserStat(String id) async { + try { + final response = await _dio.get("${APIEndpoint.userStat}/$id"); + + if (response.statusCode == 200) { + final parsedResponse = BaseResponseModel.fromJson( + response.data, + (data) => UserStatModel.fromJson(data), + ); + return parsedResponse; + } else { + return null; + } + } catch (e) { + logC.e("get user data error: $e"); + return null; + } + } } diff --git a/lib/data/services/user_storage_service.dart b/lib/data/services/user_storage_service.dart index a806829..0ce0641 100644 --- a/lib/data/services/user_storage_service.dart +++ b/lib/data/services/user_storage_service.dart @@ -2,10 +2,19 @@ import 'dart:convert'; import 'package:quiz_app/data/entity/user/user_entity.dart'; import 'package:shared_preferences/shared_preferences.dart'; +/// A lightweight wrapper around [SharedPreferences] that persists +/// the logged‑in user plus UI/feature preferences such as theme and +/// push‑notification opt‑in. class UserStorageService { + // ───────────────────── Keys ───────────────────── static const _userKey = 'user_data'; + static const _darkModeKey = 'pref_dark_mode'; + static const _pushNotifKey = 'pref_push_notification'; + + /// Cached flag used by splash / root to decide initial route. bool isLogged = false; + // ───────────────────── User CRUD ───────────────────── Future saveUser(UserEntity user) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_userKey, jsonEncode(user.toJson())); @@ -14,7 +23,6 @@ class UserStorageService { Future loadUser() async { final prefs = await SharedPreferences.getInstance(); final jsonString = prefs.getString(_userKey); - if (jsonString == null) return null; return UserEntity.fromJson(jsonDecode(jsonString)); } @@ -28,4 +36,29 @@ class UserStorageService { final prefs = await SharedPreferences.getInstance(); return prefs.containsKey(_userKey); } + + // ───────────────────── UI Preferences ───────────────────── + /// Persist the user’s theme choice. + Future setDarkMode(bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_darkModeKey, value); + } + + /// Retrieve the stored theme choice. Defaults to *false* (light mode). + Future getDarkMode() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_darkModeKey) ?? false; + } + + /// Persist the user’s push‑notification preference. + Future setPushNotification(bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_pushNotifKey, value); + } + + /// Retrieve the stored push‑notification preference. Defaults to *true*. + Future getPushNotification() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_pushNotifKey) ?? true; + } } diff --git a/lib/feature/profile/binding/profile_binding.dart b/lib/feature/profile/binding/profile_binding.dart index 89e85fd..086bfdd 100644 --- a/lib/feature/profile/binding/profile_binding.dart +++ b/lib/feature/profile/binding/profile_binding.dart @@ -1,5 +1,7 @@ import 'package:get/get.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/services/google_auth_service.dart'; +import 'package:quiz_app/data/services/user_service.dart'; import 'package:quiz_app/data/services/user_storage_service.dart'; import 'package:quiz_app/feature/profile/controller/profile_controller.dart'; @@ -7,6 +9,12 @@ class ProfileBinding extends Bindings { @override void dependencies() { Get.lazyPut(() => GoogleAuthService()); - Get.lazyPut(() => ProfileController(Get.find(), Get.find())); + if (!Get.isRegistered()) Get.lazyPut(() => UserService()); + Get.lazyPut(() => ProfileController( + Get.find(), + Get.find(), + Get.find(), + Get.find(), + )); } } diff --git a/lib/feature/profile/controller/profile_controller.dart b/lib/feature/profile/controller/profile_controller.dart index 7e7d088..042ea38 100644 --- a/lib/feature/profile/controller/profile_controller.dart +++ b/lib/feature/profile/controller/profile_controller.dart @@ -3,34 +3,93 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/core/endpoint/api_endpoint.dart'; import 'package:quiz_app/core/utils/logger.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/models/user/user_stat_model.dart'; import 'package:quiz_app/data/services/google_auth_service.dart'; +import 'package:quiz_app/data/services/user_service.dart'; import 'package:quiz_app/data/services/user_storage_service.dart'; +import 'package:url_launcher/url_launcher.dart'; class ProfileController extends GetxController { - final UserController _userController = Get.find(); - + final UserController _userController; final UserStorageService _userStorageService; final GoogleAuthService _googleAuthService; + final UserService _userService; - ProfileController(this._userStorageService, this._googleAuthService); + ProfileController( + this._userController, + this._userStorageService, + this._googleAuthService, + this._userService, + ); + // User basic info Rx get userName => _userController.userName; Rx get email => _userController.email; Rx get userImage => _userController.userImage; + Rx data = Rx(null); - final totalQuizzes = 12.obs; - final avgScore = 85.obs; + Rx birthDate = "".obs; + Rx phoneNumber = "".obs; + Rx location = "".obs; + Rx joinDate = "".obs; + Rx education = "".obs; + Rx profileImage = null.obs; + + // App settings + Rx notificationsEnabled = true.obs; + Rx darkModeEnabled = false.obs; + Rx soundEffectsEnabled = true.obs; + + // App info + Rx appName = "Quiz App".obs; + Rx appVersion = "1.0.2".obs; + Rx appDescription = "An educational quiz app to test and improve your knowledge across various subjects.".obs; + Rx companyName = "Genso Inc.".obs; + + // URLs for legal pages + final String privacyPolicyUrl = "${APIEndpoint.baseUrl}/privacy-policy"; + final String termsOfServiceUrl = "${APIEndpoint.baseUrl}/terms-of-service"; + final String helpCenterUrl = "${APIEndpoint.baseUrl}/help-center"; + final String supportEmail = "support@quizmaster.com"; + + @override + void onInit() { + super.onInit(); + loadUserStat(); + // In a real app, you would load these from your API or local storage + loadUserProfileData(); + } + + void loadUserProfileData() { + try { + birthDate.value = _userController.userData?.birthDate ?? ""; + phoneNumber.value = _userController.userData?.phone ?? ""; + // joinDate.value = _userController.userData?. ?? ""; + } catch (e, stackTrace) { + logC.e("Failed to load user profile data: $e", stackTrace: stackTrace); + } + } + + void loadUserStat() async { + try { + final result = await _userService.getUserStat(_userController.userData!.id); + if (result != null) { + data.value = result.data; + } + } catch (e, stackTrace) { + logC.e("Failed to load user stat: $e", stackTrace: stackTrace); + } + } void logout() async { try { await _googleAuthService.signOut(); - await _userStorageService.clearUser(); _userController.clearUser(); _userStorageService.isLogged = false; - Get.offAllNamed(AppRoutes.loginPage); } catch (e, stackTrace) { logC.e("Google Sign-Out Error: $e", stackTrace: stackTrace); @@ -47,4 +106,66 @@ class ProfileController extends GetxController { await context.setLocale(locale); Get.updateLocale(locale); } + + // Settings methods + void toggleNotifications() { + notificationsEnabled.value = !notificationsEnabled.value; + // In a real app, you would save this preference to storage + } + + void toggleDarkMode() { + darkModeEnabled.value = !darkModeEnabled.value; + // In a real app, you would update the theme and save preference + } + + void toggleSoundEffects() { + soundEffectsEnabled.value = !soundEffectsEnabled.value; + // In a real app, you would save this preference to storage + } + + void clearCache() { + // Implement cache clearing logic + Get.snackbar( + "Success", + "Cache cleared successfully", + snackPosition: SnackPosition.BOTTOM, + ); + } + + // Legal and support methods + void openPrivacyPolicy() async { + await _launchUrl(privacyPolicyUrl); + } + + void openTermsOfService() async { + await _launchUrl(termsOfServiceUrl); + } + + void openHelpCenter() async { + await _launchUrl(helpCenterUrl); + } + + void contactSupport() async { + final Uri emailUri = Uri( + scheme: 'mailto', + path: supportEmail, + query: 'subject=Support Request&body=Hello, I need help with...', + ); + await _launchUrl(emailUri.toString()); + } + + Future _launchUrl(String urlString) async { + try { + final url = Uri.parse(urlString); + if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { + throw Exception('Could not launch $url'); + } + } catch (e) { + Get.snackbar( + "Error", + "Could not open the link", + snackPosition: SnackPosition.BOTTOM, + ); + } + } } diff --git a/lib/feature/profile/view/components/info_row_card.dart b/lib/feature/profile/view/components/info_row_card.dart new file mode 100644 index 0000000..00fd865 --- /dev/null +++ b/lib/feature/profile/view/components/info_row_card.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/app/const/text/text_style.dart'; + +class InfoRow extends StatelessWidget { + const InfoRow({super.key, required this.icon, required this.label, required this.value}); + + final IconData icon; + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 20, color: AppColors.primaryBlue), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: AppTextStyles.caption), + Text(value, style: AppTextStyles.body), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/feature/profile/view/components/section_header_card.dart b/lib/feature/profile/view/components/section_header_card.dart new file mode 100644 index 0000000..b91f9e5 --- /dev/null +++ b/lib/feature/profile/view/components/section_header_card.dart @@ -0,0 +1,35 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/app/const/text/text_style.dart'; + +class SectionHeader extends StatelessWidget { + const SectionHeader({super.key, required this.title, required this.icon, this.onEdit}); + + final String title; + final IconData icon; + final VoidCallback? onEdit; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon(icon, size: 20, color: AppColors.primaryBlue), + const SizedBox(width: 8), + Text(title, style: AppTextStyles.subtitle.copyWith(fontWeight: FontWeight.bold)), + ], + ), + if (onEdit != null) + IconButton( + icon: Icon(LucideIcons.edit, color: AppColors.primaryBlue), + onPressed: onEdit, + tooltip: context.tr('edit_profile'), + ), + ], + ); + } +} diff --git a/lib/feature/profile/view/profile_view.dart b/lib/feature/profile/view/profile_view.dart index ee948db..7a6ff10 100644 --- a/lib/feature/profile/view/profile_view.dart +++ b/lib/feature/profile/view/profile_view.dart @@ -1,159 +1,241 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:quiz_app/app/const/text/text_style.dart'; +import 'package:quiz_app/component/app_name.dart'; import 'package:quiz_app/feature/profile/controller/profile_controller.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/feature/profile/view/components/info_row_card.dart'; +import 'package:quiz_app/feature/profile/view/components/section_header_card.dart'; class ProfileView extends GetView { const ProfileView({super.key}); @override Widget build(BuildContext context) { + const cardRadius = BorderRadius.all(Radius.circular(20)); + return Scaffold( - backgroundColor: const Color(0xFFF8F9FB), + backgroundColor: AppColors.background2, body: SafeArea( child: Padding( padding: const EdgeInsets.all(20), - child: Obx(() { - return Column( - children: [ - const SizedBox(height: 20), - _buildAvatar(), - const SizedBox(height: 12), - Text( - controller.userName.value, - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 4), - Text( - controller.email.value, - style: const TextStyle(fontSize: 14, color: Colors.grey), - ), - const SizedBox(height: 24), - _buildStats(context), - const SizedBox(height: 32), - _buildActionButton( - context.tr("edit_profile"), - Icons.edit, - controller.editProfile, - ), - const SizedBox(height: 12), - _buildActionButton( - context.tr("change_language"), - Icons.language, - () => _showLanguageDialog(context), - ), - const SizedBox(height: 12), - _buildActionButton( - context.tr("logout"), - Icons.logout, - controller.logout, - isDestructive: true, - ), - ], - ); - }), + child: Obx( + () => SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 24), + _avatar(), + // const SizedBox(height: 16), + // _userHeader(), + const SizedBox(height: 24), + _statsCard(context: context, cardRadius: cardRadius), + const SizedBox(height: 10), + _profileDetails(context: context, cardRadius: cardRadius), + const SizedBox(height: 10), + _settingsSection(context: context, cardRadius: cardRadius), + const SizedBox(height: 10), + _legalSection(context: context, cardRadius: cardRadius), + const SizedBox(height: 20), + ], + ), + ), + ), ), ), ); } - Widget _buildAvatar() { - if (controller.userImage.value != null) { - return CircleAvatar( - radius: 45, - backgroundColor: Colors.blueAccent, - backgroundImage: NetworkImage(controller.userImage.value!), - ); - } else { - return const CircleAvatar( - radius: 45, - backgroundColor: Colors.blueAccent, - child: Icon(Icons.person, size: 50, color: Colors.white), - ); - } - } + // -------------------- UTILITY WIDGETS -------------------- // - Widget _buildStats(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: const [ - BoxShadow( - color: Colors.black12, - blurRadius: 6, - offset: Offset(0, 2), - ), - ], - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + Widget _statChip(String label, String value) => Column( children: [ - _buildStatItem( - context.tr("total_quiz"), - controller.totalQuizzes.value.toString(), - ), - const SizedBox(width: 16), - _buildStatItem( - context.tr("avg_score"), - "${controller.avgScore.value}%", - ), + Text(value, style: AppTextStyles.statValue), + const SizedBox(height: 4), + Text(label, style: AppTextStyles.caption), ], - ), + ); + + Widget _settingsTile( + BuildContext context, { + required IconData icon, + required String title, + VoidCallback? onTap, + Color? iconColor, + Color? textColor, + }) { + final primary = iconColor ?? AppColors.primaryBlue; + return ListTile( + leading: Icon(icon, color: primary, size: 22), + title: Text(title, style: AppTextStyles.optionText.copyWith(color: textColor ?? AppColors.darkText)), + trailing: Icon(LucideIcons.chevronRight, color: AppColors.softGrayText, size: 18), + onTap: onTap, + dense: true, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ); } - Widget _buildStatItem(String label, String value) { - return Column( - children: [ - Text(value, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), - const SizedBox(height: 4), - Text(label, style: const TextStyle(fontSize: 13, color: Colors.grey)), - ], - ); - } + // -------------------- SECTIONS -------------------- // - Widget _buildActionButton(String title, IconData icon, VoidCallback onPressed, {bool isDestructive = false}) { - return SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - icon: Icon(icon, color: isDestructive ? Colors.red : Colors.white), - label: Text( - title, - style: TextStyle(color: isDestructive ? Colors.red : Colors.white), + Widget _avatar() => Center( + child: CircleAvatar( + radius: 50, + backgroundColor: AppColors.accentBlue, + foregroundImage: controller.userImage.value != null ? NetworkImage(controller.userImage.value!) : null, + child: controller.userImage.value == null ? Icon(LucideIcons.user, color: AppColors.primaryBlue, size: 40) : null, ), - onPressed: onPressed, - style: ElevatedButton.styleFrom( - backgroundColor: isDestructive ? Colors.red.shade50 : Colors.blueAccent, - padding: const EdgeInsets.symmetric(vertical: 14), - side: isDestructive ? const BorderSide(color: Colors.red) : BorderSide.none, + ); + + // Widget _userHeader() => Column( + // children: [ + // Text(controller.userName.value, style: AppTextStyles.title), + // Text(controller.email.value, style: AppTextStyles.subtitle), + // ], + // ); + + Widget _statsCard({required BuildContext context, required BorderRadius cardRadius}) => Card( + color: Colors.white, + elevation: 1, + shadowColor: AppColors.shadowPrimary, + shape: RoundedRectangleBorder(borderRadius: cardRadius), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _statChip(context.tr('total_quiz'), controller.data.value?.totalQuiz.toString() ?? '0'), + _statChip(context.tr('total_solve'), controller.data.value?.totalSolve.toString() ?? '0'), + _statChip(context.tr('avg_score'), '${controller.data.value?.avgScore ?? 100}%'), + ], + ), ), - ), - ); - } + ); + + Widget _profileDetails({required BuildContext context, required BorderRadius cardRadius}) => Card( + color: Colors.white, + elevation: 1, + shadowColor: AppColors.shadowPrimary, + shape: RoundedRectangleBorder(borderRadius: cardRadius), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20), + child: Column( + children: [ + SectionHeader( + title: context.tr('personal_info'), + icon: LucideIcons.userCog, + onEdit: controller.editProfile, + ), + const Divider(height: 1), + const SizedBox(height: 20), + InfoRow(icon: LucideIcons.user, label: context.tr('full_name'), value: controller.userName.value), + InfoRow( + icon: LucideIcons.cake, + label: context.tr('birth_date'), + value: controller.birthDate.value ?? context.tr('not_set'), + ), + InfoRow( + icon: LucideIcons.phone, + label: context.tr('phone'), + value: controller.phoneNumber.value ?? context.tr('not_set'), + ), + InfoRow( + icon: LucideIcons.mapPin, + label: context.tr('location'), + value: controller.location.value ?? context.tr('not_set'), + ), + InfoRow( + icon: LucideIcons.calendar, + label: context.tr('joined'), + value: controller.joinDate.value ?? context.tr('not_available'), + ), + InfoRow( + icon: LucideIcons.graduationCap, + label: context.tr('education'), + value: controller.education.value ?? context.tr('not_set'), + ), + ], + ), + ), + ); + + Widget _settingsSection({required BuildContext context, required BorderRadius cardRadius}) => Card( + color: Colors.white, + elevation: 1, + shadowColor: AppColors.shadowPrimary, + shape: RoundedRectangleBorder(borderRadius: cardRadius), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10.0), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 10), + child: SectionHeader(title: context.tr('settings'), icon: LucideIcons.settings), + ), + const Divider(height: 1), + _settingsTile(Get.context!, icon: LucideIcons.languages, title: context.tr('change_language'), onTap: () => _showLanguageDialog(Get.context!)), + _settingsTile(Get.context!, + icon: LucideIcons.logOut, title: context.tr('logout'), iconColor: Colors.red, textColor: Colors.red, onTap: controller.logout), + ], + ), + ), + ); + + Widget _legalSection({required BuildContext context, required BorderRadius cardRadius}) => Card( + color: Colors.white, + elevation: 1, + shadowColor: AppColors.shadowPrimary, + shape: RoundedRectangleBorder(borderRadius: cardRadius), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10.0), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 10), + child: SectionHeader(title: context.tr('legal_and_support'), icon: LucideIcons.shieldQuestion), + ), + const Divider(height: 1), + _settingsTile(Get.context!, icon: LucideIcons.shield, title: context.tr('privacy_policy'), onTap: controller.openPrivacyPolicy), + _settingsTile(Get.context!, icon: LucideIcons.fileText, title: context.tr('terms_of_service'), onTap: controller.openTermsOfService), + _settingsTile(Get.context!, icon: LucideIcons.helpCircle, title: context.tr('help_center'), onTap: controller.openHelpCenter), + _settingsTile(Get.context!, icon: LucideIcons.mail, title: context.tr('contact_us'), onTap: controller.contactSupport), + _settingsTile(Get.context!, icon: LucideIcons.info, title: context.tr('about_app'), onTap: () => _showAboutAppDialog(Get.context!)), + ], + ), + ), + ); void _showLanguageDialog(BuildContext context) { showDialog( context: context, builder: (_) => AlertDialog( - title: Text(context.tr("select_language")), + title: Text(context.tr('select_language'), style: AppTextStyles.title), content: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( - leading: const Icon(Icons.language), - title: const Text("English"), + leading: Icon(LucideIcons.languages, color: AppColors.primaryBlue), + title: const Text('English'), onTap: () { controller.changeLanguage(context, 'en', 'US'); Navigator.of(context).pop(); }, ), ListTile( - leading: const Icon(Icons.language), - title: const Text("Bahasa Indonesia"), + leading: Icon(LucideIcons.languages, color: AppColors.primaryBlue), + title: const Text('Bahasa Indonesia'), onTap: () { - controller.changeLanguage(context, 'id', "ID"); + controller.changeLanguage(context, 'id', 'ID'); + Navigator.of(context).pop(); + }, + ), + ListTile( + leading: Icon(LucideIcons.languages, color: AppColors.primaryBlue), + title: const Text('Malaysia'), + onTap: () { + controller.changeLanguage(context, 'ms', 'MY'); Navigator.of(context).pop(); }, ), @@ -162,4 +244,28 @@ class ProfileView extends GetView { ), ); } + + void _showAboutAppDialog(BuildContext context) { + showDialog( + context: context, + builder: (_) => AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const AppName(), + const SizedBox(height: 16), + Text(controller.appName.value, style: AppTextStyles.title), + Text("${context.tr('version')}: ${controller.appVersion.value}", style: AppTextStyles.caption), + const SizedBox(height: 16), + Text(controller.appDescription.value, textAlign: TextAlign.center, style: AppTextStyles.body), + const SizedBox(height: 16), + Text('© ${DateTime.now().year} ${controller.companyName.value}', style: AppTextStyles.caption), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(), child: Text(context.tr('close'), style: AppTextStyles.optionText)), + ], + ), + ); + } } diff --git a/pubspec.lock b/pubspec.lock index 2ec5508..ec58578 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -586,6 +586,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + url: "https://pub.dev" + source: hosted + version: "6.3.1" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79" + url: "https://pub.dev" + source: hosted + version: "6.3.16" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" + url: "https://pub.dev" + source: hosted + version: "6.3.3" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + url: "https://pub.dev" + source: hosted + version: "3.1.4" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index c190f99..627a6a5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -46,6 +46,7 @@ dependencies: easy_localization: ^3.0.7+1 percent_indicator: ^4.2.5 connectivity_plus: ^6.1.4 + url_launcher: ^6.3.1 dev_dependencies: flutter_test: From 2a93a9a371002cf2cce2b4085a7c1a599bb68f4c Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 25 May 2025 13:58:52 +0700 Subject: [PATCH 085/104] feat: adjusting on the quiz option --- lib/core/endpoint/api_endpoint.dart | 2 +- .../quiz_play/view/quiz_play_view.dart | 89 +++++++++++-------- 2 files changed, 51 insertions(+), 40 deletions(-) diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart index 2b38099..d2ec9ad 100644 --- a/lib/core/endpoint/api_endpoint.dart +++ b/lib/core/endpoint/api_endpoint.dart @@ -1,5 +1,5 @@ class APIEndpoint { - static const String baseUrl = "http://192.168.110.43:5000"; + static const String baseUrl = "http://192.168.1.14:5000"; // static const String baseUrl = "http://103.193.178.121:5000"; static const String api = "$baseUrl/api"; diff --git a/lib/feature/quiz_play/view/quiz_play_view.dart b/lib/feature/quiz_play/view/quiz_play_view.dart index d12c9ed..9b9b834 100644 --- a/lib/feature/quiz_play/view/quiz_play_view.dart +++ b/lib/feature/quiz_play/view/quiz_play_view.dart @@ -4,6 +4,7 @@ import 'package:get/get.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; import 'package:quiz_app/component/global_text_field.dart'; import 'package:quiz_app/data/models/quiz/question/option_question_model.dart'; +import 'package:quiz_app/data/models/quiz/question/true_false_question_model.dart'; import 'package:quiz_app/feature/quiz_play/controller/quiz_play_controller.dart'; class QuizPlayView extends GetView { @@ -11,31 +12,35 @@ class QuizPlayView extends GetView { @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: const Color(0xFFF9FAFB), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(16), - child: Obx(() { - if (!controller.isStarting.value) { - return _buildCountdownScreen(context); - } + return PopScope( + canPop: false, + child: Scaffold( + backgroundColor: const Color(0xFFF9FAFB), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Obx(() { + if (!controller.isStarting.value) { + return _buildCountdownScreen(context); + } - return ListView( - children: [ - _buildCustomAppBar(context), - const SizedBox(height: 20), - _buildProgressBar(), - const SizedBox(height: 20), - _buildQuestionIndicator(context), - const SizedBox(height: 12), - _buildQuestionText(), - const SizedBox(height: 30), - Expanded(child: _buildAnswerSection(context)), - _buildNextButton(context), - ], - ); - }), + return ListView( + children: [ + _buildCustomAppBar(context), + const SizedBox(height: 20), + _buildProgressBar(), + const SizedBox(height: 20), + _buildQuestionIndicator(context), + const SizedBox(height: 12), + _buildQuestionText(), + const SizedBox(height: 30), + _buildAnswerSection(context), + Spacer(), + _buildNextButton(context), + ], + ); + }), + ), ), ), ); @@ -305,22 +310,28 @@ class QuizPlayView extends GetView { final question = controller.currentQuestion; if (question is OptionQuestion) { - return AnimatedList( - initialItemCount: question.options.length, - itemBuilder: (context, index, animation) { - return SlideTransition( - position: Tween( - begin: const Offset(1, 0), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: animation, - curve: Interval(index * 0.1, 1.0, curve: Curves.easeOut), - )), - child: _buildOptionButton(question.options[index], index), - ); - }, + return ConstrainedBox( + constraints: BoxConstraints( + minHeight: 100, + maxHeight: 300, + ), + child: AnimatedList( + initialItemCount: question.options.length, + itemBuilder: (context, index, animation) { + return SlideTransition( + position: Tween( + begin: const Offset(1, 0), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: animation, + curve: Interval(index * 0.1, 1.0, curve: Curves.easeOut), + )), + child: _buildOptionButton(question.options[index], index), + ); + }, + ), ); - } else if (question.type == 'true_false') { + } else if (question is TrueFalseQuestion) { return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ From a7f6ce8e7e1781057093b61e61a2b60a20bfec93 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 25 May 2025 15:08:13 +0700 Subject: [PATCH 086/104] feat: adding keep alive app when open --- lib/main.dart | 3 +++ pubspec.lock | 40 ++++++++++++++++++++++++++++++++++++++++ pubspec.yaml | 1 + 3 files changed, 44 insertions(+) diff --git a/lib/main.dart b/lib/main.dart index 2f584e3..11c14fb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:quiz_app/app/app.dart'; import 'package:quiz_app/core/utils/logger.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; void main() { runZonedGuarded(() async { @@ -18,6 +19,8 @@ void main() { WidgetsFlutterBinding.ensureInitialized(); await EasyLocalization.ensureInitialized(); + WakelockPlus.enable(); + runApp( EasyLocalization( supportedLocales: [ diff --git a/pubspec.lock b/pubspec.lock index ec58578..f1c0492 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -365,6 +365,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "7976bfe4c583170d6cdc7077e3237560b364149fcd268b5f53d95a991963b191" + url: "https://pub.dev" + source: hosted + version: "8.3.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "6c935fb612dff8e3cc9632c2b301720c77450a126114126ffaafe28d2e87956c" + url: "https://pub.dev" + source: hosted + version: "3.2.0" path: dependency: transitive description: @@ -666,6 +682,22 @@ packages: url: "https://pub.dev" source: hosted version: "14.3.0" + wakelock_plus: + dependency: "direct main" + description: + name: wakelock_plus + sha256: a474e314c3e8fb5adef1f9ae2d247e57467ad557fa7483a2b895bc1b421c5678 + url: "https://pub.dev" + source: hosted + version: "1.3.2" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: e10444072e50dbc4999d7316fd303f7ea53d31c824aa5eb05d7ccbdd98985207 + url: "https://pub.dev" + source: hosted + version: "1.2.3" web: dependency: transitive description: @@ -674,6 +706,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e + url: "https://pub.dev" + source: hosted + version: "5.10.1" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 627a6a5..8dd467e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -47,6 +47,7 @@ dependencies: percent_indicator: ^4.2.5 connectivity_plus: ^6.1.4 url_launcher: ^6.3.1 + wakelock_plus: ^1.3.2 dev_dependencies: flutter_test: From c84133a37216cec14740d78358d99b5e1fd058c1 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 25 May 2025 15:26:45 +0700 Subject: [PATCH 087/104] fix: interface on the quiz result --- assets/translations/en-US.json | 12 +- assets/translations/id-ID.json | 12 +- assets/translations/ms-MY.json | 12 +- .../widget/quiz_item_wa_component.dart | 217 +++++++++++++----- 4 files changed, 192 insertions(+), 61 deletions(-) diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index c41fea5..0da7b79 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -47,12 +47,10 @@ "avg_score": "Average Score", "history_detail_title": "Quiz Detail", - "correct_answer": "Correct", "score": "Score", "time_taken": "Time", "duration_seconds": "{second}s", - "your_answer": "Your answer: {answer}", "question_type_option": "Multiple Choice", "question_type_fill": "Fill in the Blank", "question_type_true_false": "True / False", @@ -105,5 +103,13 @@ "contact_us": "Contact Us", "about_app": "About App", "version": "Version", - "close": "Close" + "close": "Close", + + "your_answer": "Your answer: {answer}", + "correct_answer": "Correct answer: {answer}", + "not_answered": "Not Answered", + "seconds_suffix": "s", + "quiz_type_option": "Multiple Choice", + "quiz_type_true_false": "True or False", + "quiz_type_fill_the_blank": "Fill in the Blank" } diff --git a/assets/translations/id-ID.json b/assets/translations/id-ID.json index 75b6794..da60f76 100644 --- a/assets/translations/id-ID.json +++ b/assets/translations/id-ID.json @@ -47,12 +47,10 @@ "avg_score": "Skor Rata-rata", "history_detail_title": "Detail Kuis", - "correct_answer": "Benar", "score": "Skor", "time_taken": "Waktu", "duration_seconds": "{second} detik", - "your_answer": "Jawaban kamu: {answer}", "question_type_option": "Pilihan Ganda", "question_type_fill": "Isian Kosong", "question_type_true_false": "Benar / Salah", @@ -105,5 +103,13 @@ "contact_us": "Hubungi Kami", "about_app": "Tentang Aplikasi", "version": "Versi", - "close": "Tutup" + "close": "Tutup", + + "your_answer": "Jawabanmu: {answer}", + "correct_answer": "Jawaban benar: {answer}", + "not_answered": "Tidak Menjawab", + "seconds_suffix": "d", + "quiz_type_option": "Pilihan Ganda", + "quiz_type_true_false": "Benar atau Salah", + "quiz_type_fill_the_blank": "Isian Kosong" } diff --git a/assets/translations/ms-MY.json b/assets/translations/ms-MY.json index 4f2558f..fd50edb 100644 --- a/assets/translations/ms-MY.json +++ b/assets/translations/ms-MY.json @@ -47,12 +47,10 @@ "avg_score": "Skor Purata", "history_detail_title": "Butiran Kuiz", - "correct_answer": "Betul", "score": "Skor", "time_taken": "Masa", "duration_seconds": "{second} saat", - "your_answer": "Jawapan anda: {answer}", "question_type_option": "Pilihan Berganda", "question_type_fill": "Isi Tempat Kosong", "question_type_true_false": "Betul / Salah", @@ -102,5 +100,13 @@ "contact_us": "Hubungi Kami", "about_app": "Mengenai Aplikasi", "version": "Versi", - "close": "Tutup" + "close": "Tutup", + + "your_answer": "Jawapan anda: {answer}", + "correct_answer": "Jawapan betul: {answer}", + "not_answered": "Tidak Dijawab", + "seconds_suffix": "s", + "quiz_type_option": "Pilihan Jawapan", + "quiz_type_true_false": "Betul atau Salah", + "quiz_type_fill_the_blank": "Isi Tempat Kosong" } diff --git a/lib/component/widget/quiz_item_wa_component.dart b/lib/component/widget/quiz_item_wa_component.dart index c650fe3..d56d070 100644 --- a/lib/component/widget/quiz_item_wa_component.dart +++ b/lib/component/widget/quiz_item_wa_component.dart @@ -4,16 +4,13 @@ import 'package:lucide_icons/lucide_icons.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; import 'package:quiz_app/app/const/text/text_style.dart'; +/// Single quiz result tile. +/// Shows the question, the user's answer, correctness, time spent, and per‑option feedback. +/// +/// * Text strings are fully localised via `easy_localization`. +/// * Long answers now wrap to the next line rather than overflowing. +/// * Option chips highlight both the correct answer and the user's incorrect choice (if any). class QuizItemWAComponent extends StatelessWidget { - final int index; - final String question; - final String type; - final dynamic userAnswer; - final dynamic targetAnswer; - final bool isCorrect; - final double timeSpent; - final List? options; - const QuizItemWAComponent({ super.key, required this.index, @@ -26,20 +23,44 @@ class QuizItemWAComponent extends StatelessWidget { this.options, }); - bool get isOptionType => type == 'option'; + /// One‑based question index. + final int index; + + /// The question text. + final String question; + + /// Question type: `option`, `true_false`, or `fill_the_blank`. + final String type; + + /// Raw user answer (index, bool or string). `-1`/`null` means no answer. + final dynamic userAnswer; + + /// Raw correct answer (index, bool or string). + final dynamic targetAnswer; + + /// Whether the user answered correctly. + final bool isCorrect; + + /// Time spent answering (seconds). + final double timeSpent; + + /// Option texts for option‑type questions. + final List? options; + + bool get _isOptionType => type == 'option'; @override Widget build(BuildContext context) { return Container( width: double.infinity, - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.04), + color: Colors.black.withOpacity(.04), blurRadius: 6, offset: const Offset(0, 2), ), @@ -48,41 +69,89 @@ class QuizItemWAComponent extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // ————————————————— Question text Text( '$index. $question', + softWrap: true, style: AppTextStyles.title.copyWith(fontSize: 16, fontWeight: FontWeight.w600), ), - const SizedBox(height: 16), - if (isOptionType && options != null) _buildOptions(), + + if (_isOptionType && options != null) ...[ + const SizedBox(height: 16), + _OptionsList( + options: options!, + userAnswer: userAnswer, + targetAnswer: targetAnswer, + ), + ], + const SizedBox(height: 12), - _buildAnswerIndicator(context), + _AnswerIndicator( + isCorrect: isCorrect, + isAnswered: userAnswer != null && userAnswer != -1, + userAnswerText: _buildUserAnswerText(), + correctAnswerText: _buildCorrectAnswerText(), + ), const SizedBox(height: 16), const Divider(height: 24, color: AppColors.shadowPrimary), - _buildMetadata(), + _MetaBar(type: type, timeSpent: timeSpent), ], ), ); } - Widget _buildOptions() { + // ——————————————————————————————————————————————————————— Helpers + String _buildUserAnswerText() { + if (userAnswer == null || userAnswer == -1) { + return tr('not_answered'); + } + if (_isOptionType) { + final idx = int.tryParse(userAnswer.toString()) ?? -1; + if (idx >= 0 && idx < (options?.length ?? 0)) return options![idx]; + } + return userAnswer.toString(); + } + + String _buildCorrectAnswerText() { + if (_isOptionType && options != null) { + final idx = int.tryParse(targetAnswer.toString()) ?? -1; + if (idx >= 0 && idx < options!.length) return options![idx]; + } + return targetAnswer.toString(); + } +} + +// ————————————————————————————————————————————————————————— Sub‑widgets + +class _OptionsList extends StatelessWidget { + const _OptionsList({ + required this.options, + required this.userAnswer, + required this.targetAnswer, + }); + + final List options; + final dynamic userAnswer; + final dynamic targetAnswer; + + @override + Widget build(BuildContext context) { return Column( - children: options!.asMap().entries.map((entry) { - final int optIndex = entry.key; - final String text = entry.value; + children: List.generate(options.length, (i) { + final text = options[i]; + final bool isCorrectAnswer = i == targetAnswer; + final bool isUserWrongAnswer = i == userAnswer && !isCorrectAnswer; - final bool isCorrectAnswer = optIndex == targetAnswer; - final bool isUserWrongAnswer = optIndex == userAnswer && !isCorrectAnswer; - - Color? backgroundColor; + Color? bg; IconData icon = LucideIcons.circle; Color iconColor = AppColors.shadowPrimary; if (isCorrectAnswer) { - backgroundColor = AppColors.primaryBlue.withValues(alpha: 0.15); + bg = AppColors.primaryBlue.withOpacity(.15); icon = LucideIcons.checkCircle2; iconColor = AppColors.primaryBlue; } else if (isUserWrongAnswer) { - backgroundColor = Colors.red.withValues(alpha: 0.15); + bg = Colors.red.withOpacity(.15); icon = LucideIcons.xCircle; iconColor = Colors.red; } @@ -92,58 +161,79 @@ class QuizItemWAComponent extends StatelessWidget { margin: const EdgeInsets.symmetric(vertical: 6), padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), decoration: BoxDecoration( - color: backgroundColor, + color: bg, borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.shadowPrimary), ), child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(icon, size: 16, color: iconColor), const SizedBox(width: 8), - Flexible( - child: Text(text, style: AppTextStyles.optionText), + Expanded( + child: Text( + text, + style: AppTextStyles.optionText, + softWrap: true, + ), ), ], ), ); - }).toList(), + }), ); } +} - Widget _buildAnswerIndicator(BuildContext context) { +class _AnswerIndicator extends StatelessWidget { + const _AnswerIndicator({ + required this.isCorrect, + required this.isAnswered, + required this.userAnswerText, + required this.correctAnswerText, + }); + + final bool isCorrect; + final bool isAnswered; + final String userAnswerText; + final String correctAnswerText; + + @override + Widget build(BuildContext context) { final icon = isCorrect ? LucideIcons.checkCircle2 : LucideIcons.xCircle; final color = isCorrect ? AppColors.primaryBlue : Colors.red; - final String userAnswerText; - - if (userAnswer == null || userAnswer == -1) { - userAnswerText = "Tidak Menjawab"; - } else { - userAnswerText = isOptionType ? options![userAnswer] : userAnswer.toString(); - } - - final String correctAnswerText = targetAnswer.toString(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(icon, color: color, size: 18), const SizedBox(width: 8), - Text( - context.tr('your_answer', namedArgs: {'answer': userAnswerText}), - style: AppTextStyles.statValue, + Expanded( + child: Text( + // "Jawabanmu: " + tr('your_answer', namedArgs: {'answer': userAnswerText}), + style: AppTextStyles.statValue, + softWrap: true, + ), ), ], ), - if (!isCorrect && !isOptionType) ...[ + if (!isCorrect && isAnswered) ...[ const SizedBox(height: 6), Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SizedBox(width: 26), - Text( - 'Jawaban benar: $correctAnswerText', - style: AppTextStyles.caption, + const SizedBox(width: 26), // align with text above + Expanded( + child: Text( + // "Jawaban benar: " + tr('correct_answer', namedArgs: {'answer': correctAnswerText}), + style: AppTextStyles.caption, + softWrap: true, + ), ), ], ), @@ -151,18 +241,41 @@ class QuizItemWAComponent extends StatelessWidget { ], ); } +} - Widget _buildMetadata() { +class _MetaBar extends StatelessWidget { + const _MetaBar({required this.type, required this.timeSpent}); + final String type; + final double timeSpent; + + @override + Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - _metaItem(icon: LucideIcons.helpCircle, label: type), - _metaItem(icon: LucideIcons.clock3, label: '${timeSpent.toStringAsFixed(1)}s'), + _MetaItem( + icon: LucideIcons.helpCircle, + label: tr( + 'quiz_type_$type', + ), + ), + _MetaItem( + icon: LucideIcons.clock3, + label: '${timeSpent.toStringAsFixed(1)}${tr('seconds_suffix')}', + ), ], ); } +} - Widget _metaItem({required IconData icon, required String label}) { +class _MetaItem extends StatelessWidget { + const _MetaItem({required this.icon, required this.label}); + + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { return Row( children: [ Icon(icon, size: 16, color: AppColors.primaryBlue), From 661930d2bddae46b070e321ebcf568dd9cb18d5d Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 25 May 2025 16:03:52 +0700 Subject: [PATCH 088/104] fix: logic on the interface greating time for user --- assets/translations/en-US.json | 28 +++++++++++++++-- assets/translations/id-ID.json | 8 ++++- .../widget/quiz_item_wa_component.dart | 1 + .../detail_quiz/view/detail_quix_view.dart | 31 ++++++------------- .../history/view/detail_history_view.dart | 2 +- .../home/controller/home_controller.dart | 17 ++++++++++ .../home/view/component/user_gretings.dart | 29 ++++++++++++++--- lib/feature/home/view/home_page.dart | 1 + 8 files changed, 88 insertions(+), 29 deletions(-) diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index 0da7b79..b5831d8 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -1,5 +1,11 @@ { - "greeting_time": "Good Afternoon", + "greeting_time": { + "morning": "Good Morning", + "afternoon": "Good Afternoon", + "evening": "Good Evening", + "night": "Good Night" + }, + "greeting_user": "Hello {user}", "create_room": "Create Room", "join_room": "Join Room", @@ -106,10 +112,28 @@ "close": "Close", "your_answer": "Your answer: {answer}", + "correct": "Correct", "correct_answer": "Correct answer: {answer}", "not_answered": "Not Answered", "seconds_suffix": "s", "quiz_type_option": "Multiple Choice", "quiz_type_true_false": "True or False", - "quiz_type_fill_the_blank": "Fill in the Blank" + "quiz_type_fill_the_blank": "Fill in the Blank", + + "quiz_detail_title": "Quiz Detail", + "question_label": "Question", + "duration_label": "Duration", + "minutes_suffix": "minutes", + "start_quiz": "Start Quiz", + + "duration": { + "second": "{} second", + "minute": "{} minute", + "hour": "{} hour" + }, + "duration_suffix": { + "second": "{} s", + "minute": "{} m", + "hour": "{} h" + } } diff --git a/assets/translations/id-ID.json b/assets/translations/id-ID.json index da60f76..fdd2833 100644 --- a/assets/translations/id-ID.json +++ b/assets/translations/id-ID.json @@ -1,5 +1,11 @@ { - "greeting_time": "Selamat Siang", + "greeting_time": { + "morning": "Selamat Pagi", + "afternoon": "Selamat Siang", + "evening": "Selamat Sore", + "night": "Selamat Malam" + }, + "greeting_user": "Halo {user}", "create_room": "Buat Ruangan", "join_room": "Gabung Ruang", diff --git a/lib/component/widget/quiz_item_wa_component.dart b/lib/component/widget/quiz_item_wa_component.dart index d56d070..04caebd 100644 --- a/lib/component/widget/quiz_item_wa_component.dart +++ b/lib/component/widget/quiz_item_wa_component.dart @@ -231,6 +231,7 @@ class _AnswerIndicator extends StatelessWidget { child: Text( // "Jawaban benar: " tr('correct_answer', namedArgs: {'answer': correctAnswerText}), + style: AppTextStyles.caption, softWrap: true, ), diff --git a/lib/feature/detail_quiz/view/detail_quix_view.dart b/lib/feature/detail_quiz/view/detail_quix_view.dart index b597eda..3c467ea 100644 --- a/lib/feature/detail_quiz/view/detail_quix_view.dart +++ b/lib/feature/detail_quiz/view/detail_quix_view.dart @@ -17,9 +17,9 @@ class DetailQuizView extends GetView { appBar: AppBar( backgroundColor: AppColors.background, elevation: 0, - title: const Text( - 'Detail Quiz', - style: TextStyle( + title: Text( + tr('quiz_detail_title'), + style: const TextStyle( color: AppColors.darkText, fontWeight: FontWeight.bold, ), @@ -32,7 +32,7 @@ class DetailQuizView extends GetView { padding: const EdgeInsets.all(20), child: Obx( () => controller.isLoading.value - ? Center(child: LoadingWidget()) + ? const Center(child: LoadingWidget()) : SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -67,19 +67,16 @@ class DetailQuizView extends GetView { const Icon(Icons.timer_rounded, size: 16, color: AppColors.softGrayText), const SizedBox(width: 6), Text( - '${controller.data.limitDuration ~/ 60} menit', + '${controller.data.limitDuration ~/ 60} ${tr('minutes_suffix')}', style: const TextStyle(fontSize: 12, color: AppColors.softGrayText), ), ], ), const SizedBox(height: 20), + + GlobalButton(text: tr('start_quiz'), onPressed: controller.goToPlayPage), const SizedBox(height: 20), - GlobalButton(text: "Kerjakan", onPressed: controller.goToPlayPage), - const SizedBox(height: 20), - // GlobalButton(text: "buat ruangan", onPressed: () {}), - - const SizedBox(height: 20), const Divider(thickness: 1.2, color: AppColors.borderLight), const SizedBox(height: 20), @@ -113,7 +110,7 @@ class DetailQuizView extends GetView { border: Border.all(color: AppColors.borderLight), boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.05), + color: Colors.black.withOpacity(0.05), blurRadius: 6, offset: const Offset(2, 2), ), @@ -123,7 +120,7 @@ class DetailQuizView extends GetView { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Soal $index', + '${tr('question_label')} $index', style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, @@ -147,17 +144,9 @@ class DetailQuizView extends GetView { color: AppColors.darkText, ), ), - // const SizedBox(height: 12), - // Text( - // 'Jawaban: ${question.targetAnswer}', - // style: const TextStyle( - // fontSize: 14, - // color: AppColors.softGrayText, - // ), - // ), const SizedBox(height: 8), Text( - 'Durasi: ${question.duration} detik', + '${tr('duration_label')}: ${question.duration} ${tr('seconds_suffix')}', style: const TextStyle( fontSize: 12, color: AppColors.softGrayText, diff --git a/lib/feature/history/view/detail_history_view.dart b/lib/feature/history/view/detail_history_view.dart index 413ca42..9818076 100644 --- a/lib/feature/history/view/detail_history_view.dart +++ b/lib/feature/history/view/detail_history_view.dart @@ -107,7 +107,7 @@ class DetailHistoryView extends GetView { children: [ _buildStatItem( icon: LucideIcons.checkCircle2, - label: context.tr('correct_answer'), + label: tr('correct'), value: "${quiz.totalCorrect}/${quiz.questionListings.length}", color: Colors.green, ), diff --git a/lib/feature/home/controller/home_controller.dart b/lib/feature/home/controller/home_controller.dart index ecdc870..3dec5fc 100644 --- a/lib/feature/home/controller/home_controller.dart +++ b/lib/feature/home/controller/home_controller.dart @@ -21,6 +21,8 @@ class HomeController extends GetxController { this._subjectService, ); + RxInt timeStatus = 1.obs; + Rx get userName => _userController.userName; Rx get userImage => _userController.userImage; @@ -31,6 +33,7 @@ class HomeController extends GetxController { @override void onInit() { _getRecomendationQuiz(); + _getGreetingStatusByTime(); loadSubjectData(); super.onInit(); } @@ -68,4 +71,18 @@ class HomeController extends GetxController { AppRoutes.listingQuizPage, arguments: {"page": page, "id": subjectId, "subject_name": subjecName}, ); + + void _getGreetingStatusByTime() { + final hour = DateTime.now().hour; + + if (hour >= 5 && hour < 12) { + timeStatus.value = 1; + } else if (hour >= 12 && hour < 15) { + timeStatus.value = 2; + } else if (hour >= 15 && hour < 18) { + timeStatus.value = 3; + } else { + timeStatus.value = 4; + } + } } diff --git a/lib/feature/home/view/component/user_gretings.dart b/lib/feature/home/view/component/user_gretings.dart index 7b55af5..147e58d 100644 --- a/lib/feature/home/view/component/user_gretings.dart +++ b/lib/feature/home/view/component/user_gretings.dart @@ -4,7 +4,13 @@ import 'package:flutter/material.dart'; class UserGretingsComponent extends StatelessWidget { final String userName; final String? userImage; - const UserGretingsComponent({super.key, required this.userName, required this.userImage}); + final int greatingStatus; + const UserGretingsComponent({ + super.key, + required this.userName, + required this.userImage, + required this.greatingStatus, + }); @override Widget build(BuildContext context) { @@ -35,11 +41,11 @@ class UserGretingsComponent extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - context.tr("greeting_time"), - style: TextStyle(fontWeight: FontWeight.bold), + tr(_getGreetingKey(greatingStatus)), + style: const TextStyle(fontWeight: FontWeight.bold), ), Text( - context.tr("greeting_user", namedArgs: {"user": userName}), + tr("greeting_user", namedArgs: {"user": userName}), style: TextStyle(fontWeight: FontWeight.w500), ), ], @@ -52,4 +58,19 @@ class UserGretingsComponent extends StatelessWidget { ], ); } + + String _getGreetingKey(int status) { + switch (status) { + case 1: + return 'greeting_time.morning'; + case 2: + return 'greeting_time.afternoon'; + case 3: + return 'greeting_time.evening'; + case 4: + return 'greeting_time.night'; + default: + return 'greeting_time.morning'; // fallback + } + } } diff --git a/lib/feature/home/view/home_page.dart b/lib/feature/home/view/home_page.dart index d815de6..ce6a2cb 100644 --- a/lib/feature/home/view/home_page.dart +++ b/lib/feature/home/view/home_page.dart @@ -27,6 +27,7 @@ class HomeView extends GetView { () => UserGretingsComponent( userName: controller.userName.value, userImage: controller.userImage.value, + greatingStatus: controller.timeStatus.value, ), ), const SizedBox(height: 20), From 5e27f09921c07c75b6f648f5d3dec12b17aff288 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 25 May 2025 17:32:58 +0700 Subject: [PATCH 089/104] feat: adjustment on the translation --- assets/translations/id-ID.json | 4 +++- lib/feature/home/view/home_page.dart | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/assets/translations/id-ID.json b/assets/translations/id-ID.json index fdd2833..1ec381a 100644 --- a/assets/translations/id-ID.json +++ b/assets/translations/id-ID.json @@ -117,5 +117,7 @@ "seconds_suffix": "d", "quiz_type_option": "Pilihan Ganda", "quiz_type_true_false": "Benar atau Salah", - "quiz_type_fill_the_blank": "Isian Kosong" + "quiz_type_fill_the_blank": "Isian Kosong", + + "start_quiz": "Start Quiz" } diff --git a/lib/feature/home/view/home_page.dart b/lib/feature/home/view/home_page.dart index ce6a2cb..7032b0d 100644 --- a/lib/feature/home/view/home_page.dart +++ b/lib/feature/home/view/home_page.dart @@ -34,7 +34,6 @@ class HomeView extends GetView { ], ), ), - // ButtonOption di luar Padding ButtonOption( onCreate: controller.goToQuizCreation, onCreateRoom: controller.goToRoomMaker, From e5f84ee727eba64ce6ec0a2dc06d7cd2f967a02a Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 25 May 2025 21:11:51 +0700 Subject: [PATCH 090/104] feat: adding populer quiz logic and fix the interface --- lib/core/endpoint/api_endpoint.dart | 3 +- lib/data/services/quiz_service.dart | 24 ++++++++- .../home/controller/home_controller.dart | 2 +- .../controller/listing_quiz_controller.dart | 4 +- .../controller/room_maker_controller.dart | 2 +- .../search/controller/search_controller.dart | 15 ++++-- lib/feature/search/view/search_view.dart | 52 +++++++++++-------- 7 files changed, 70 insertions(+), 32 deletions(-) diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart index d2ec9ad..ee5556b 100644 --- a/lib/core/endpoint/api_endpoint.dart +++ b/lib/core/endpoint/api_endpoint.dart @@ -14,7 +14,8 @@ class APIEndpoint { static const String quizAnswerSession = "/quiz/answer/session"; static const String userQuiz = "/quiz/user"; - static const String quizRecomendation = "/quiz/recomendation"; + static const String quizPopuler = "/quiz/populer"; + static const String quizRecommendation = "/quiz/recommendation"; static const String quizSearch = "/quiz/search"; static const String historyQuiz = "/history"; diff --git a/lib/data/services/quiz_service.dart b/lib/data/services/quiz_service.dart index 80cfa4e..14eaed0 100644 --- a/lib/data/services/quiz_service.dart +++ b/lib/data/services/quiz_service.dart @@ -80,9 +80,29 @@ class QuizService extends GetxService { } } - Future>?> recomendationQuiz({int page = 1, int amount = 3}) async { + Future>?> populerQuiz({int page = 1, int amount = 3}) async { try { - final response = await dio.get("${APIEndpoint.quizRecomendation}?page=$page&limit=$amount"); + final response = await dio.get("${APIEndpoint.quizPopuler}?page=$page&limit=$amount"); + + if (response.statusCode == 200) { + final parsedResponse = BaseResponseModel>.fromJson( + response.data, + (data) => (data as List).map((e) => QuizListingModel.fromJson(e as Map)).toList(), + ); + return parsedResponse; + } else { + logC.e("Failed to fetch recommendation quizzes. Status: ${response.statusCode}"); + return null; + } + } catch (e) { + logC.e("Error fetching recommendation quizzes: $e"); + return null; + } + } + + Future>?> recommendationQuiz({int page = 1, int amount = 3, String userId = ""}) async { + try { + final response = await dio.get("${APIEndpoint.quizRecommendation}?page=$page&limit=$amount&user_id$userId"); if (response.statusCode == 200) { final parsedResponse = BaseResponseModel>.fromJson( diff --git a/lib/feature/home/controller/home_controller.dart b/lib/feature/home/controller/home_controller.dart index 3dec5fc..17a3359 100644 --- a/lib/feature/home/controller/home_controller.dart +++ b/lib/feature/home/controller/home_controller.dart @@ -39,7 +39,7 @@ class HomeController extends GetxController { } void _getRecomendationQuiz() async { - BaseResponseModel? response = await _quizService.recomendationQuiz(); + BaseResponseModel? response = await _quizService.recommendationQuiz(userId: _userController.userData!.id); if (response != null) { data.assignAll(response.data as List); } diff --git a/lib/feature/listing_quiz/controller/listing_quiz_controller.dart b/lib/feature/listing_quiz/controller/listing_quiz_controller.dart index 8a5db66..329a5ec 100644 --- a/lib/feature/listing_quiz/controller/listing_quiz_controller.dart +++ b/lib/feature/listing_quiz/controller/listing_quiz_controller.dart @@ -67,7 +67,7 @@ class ListingQuizController extends GetxController { isLoading.value = true; - final response = await _quizService.recomendationQuiz(page: currentPage, amount: amountQuiz); + final response = await _quizService.populerQuiz(page: currentPage, amount: amountQuiz); _handleResponse(response, resetPage: resetPage); isLoading.value = false; @@ -96,7 +96,7 @@ class ListingQuizController extends GetxController { if (isSearchMode && currentSubjectId != null) { response = await _quizService.searchQuiz("", currentPage, subjectId: currentSubjectId!); } else { - response = await _quizService.recomendationQuiz(page: currentPage, amount: amountQuiz); + response = await _quizService.populerQuiz(page: currentPage, amount: amountQuiz); } _handleResponse(response, resetPage: false); diff --git a/lib/feature/room_maker/controller/room_maker_controller.dart b/lib/feature/room_maker/controller/room_maker_controller.dart index a271f36..3ace6f1 100644 --- a/lib/feature/room_maker/controller/room_maker_controller.dart +++ b/lib/feature/room_maker/controller/room_maker_controller.dart @@ -61,7 +61,7 @@ class RoomMakerController extends GetxController { if (isOnwQuiz.value) { response = await _quizService.userQuiz(_userController.userData!.id, currentPage); } else { - response = await _quizService.recomendationQuiz(page: currentPage, amount: 5); + response = await _quizService.populerQuiz(page: currentPage, amount: 5); } if (response != null) { diff --git a/lib/feature/search/controller/search_controller.dart b/lib/feature/search/controller/search_controller.dart index 1759638..c278014 100644 --- a/lib/feature/search/controller/search_controller.dart +++ b/lib/feature/search/controller/search_controller.dart @@ -22,13 +22,15 @@ class SearchQuizController extends GetxController { final searchText = ''.obs; RxList recommendationQData = [].obs; + RxList populerQData = [].obs; RxList searchQData = [].obs; RxList subjects = [].obs; @override void onInit() { - getRecomendation(); + _getRecommendation(); + _getPopuler(); loadSubjectData(); super.onInit(); searchController.addListener(() { @@ -41,8 +43,15 @@ class SearchQuizController extends GetxController { ); } - void getRecomendation() async { - BaseResponseModel? response = await _quizService.recomendationQuiz(); + void _getPopuler() async { + BaseResponseModel? response = await _quizService.populerQuiz(); + if (response != null) { + populerQData.assignAll(response.data as List); + } + } + + void _getRecommendation() async { + BaseResponseModel? response = await _quizService.recommendationQuiz(); if (response != null) { recommendationQData.assignAll(response.data as List); } diff --git a/lib/feature/search/view/search_view.dart b/lib/feature/search/view/search_view.dart index 4252442..f0ea3f4 100644 --- a/lib/feature/search/view/search_view.dart +++ b/lib/feature/search/view/search_view.dart @@ -5,6 +5,7 @@ import 'package:quiz_app/app/const/colors/app_colors.dart'; import 'package:quiz_app/app/const/enums/listing_type.dart'; import 'package:quiz_app/component/quiz_container_component.dart'; import 'package:quiz_app/component/widget/recomendation_component.dart'; +import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; import 'package:quiz_app/data/models/subject/subject_model.dart'; import 'package:quiz_app/feature/search/controller/search_controller.dart'; @@ -31,22 +32,15 @@ class SearchView extends GetView { (e) => QuizContainerComponent(data: e, onTap: controller.goToDetailPage), ) ] else ...[ - Obx( - () => RecomendationComponent( - title: context.tr('quiz_recommendation'), - datas: controller.recommendationQData.toList(), - itemOnTap: controller.goToDetailPage, - allOnTap: () => controller.goToListingsQuizPage(ListingType.recomendation), - ), + _buildRecommendationSection( + context.tr('quiz_recommendation'), + controller.recommendationQData, + () => controller.goToListingsQuizPage(ListingType.recomendation), ), - const SizedBox(height: 30), - Obx( - () => RecomendationComponent( - title: context.tr('quiz_popular'), - datas: controller.recommendationQData.toList(), - itemOnTap: controller.goToDetailPage, - allOnTap: () => controller.goToListingsQuizPage(ListingType.populer), - ), + _buildRecommendationSection( + context.tr('quiz_popular'), + controller.populerQData, + () => controller.goToListingsQuizPage(ListingType.populer), ), ], ], @@ -88,12 +82,16 @@ class SearchView extends GetView { runSpacing: 1, children: data.map((cat) { return InkWell( - onTap: () => controller.goToListingsQuizPage(ListingType.subject, subjectId: cat.id, subjecName: cat.alias), + onTap: () => controller.goToListingsQuizPage( + ListingType.subject, + subjectId: cat.id, + subjecName: cat.alias, + ), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), margin: const EdgeInsets.symmetric(vertical: 2), decoration: BoxDecoration( - color: Color(0xFFD6E4FF), + color: const Color(0xFFD6E4FF), borderRadius: BorderRadius.circular(15), ), child: Text( @@ -110,9 +108,19 @@ class SearchView extends GetView { ); } - // Widget _buildQuizList({int count = 3}) { - // return Column( - // children: List.generate(count, (_) => const QuizContainerComponent()), - // ); - // } + Widget _buildRecommendationSection( + String title, + RxList data, + VoidCallback onAllTap, + ) { + return SizedBox( + height: 410, + child: Obx(() => RecomendationComponent( + title: title, + datas: data.toList(), + itemOnTap: controller.goToDetailPage, + allOnTap: onAllTap, + )), + ); + } } From 43fe1b275a114044e980a819675d05863cfe8691 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 25 May 2025 22:05:55 +0700 Subject: [PATCH 091/104] fix: user profile data --- lib/data/entity/user/user_entity.dart | 4 + .../models/login/login_response_model.dart | 20 ++--- .../login/controllers/login_controller.dart | 3 + .../controller/profile_controller.dart | 5 +- lib/feature/profile/view/profile_view.dart | 82 +++++++++---------- 5 files changed, 61 insertions(+), 53 deletions(-) diff --git a/lib/data/entity/user/user_entity.dart b/lib/data/entity/user/user_entity.dart index 733ad60..f98fb41 100644 --- a/lib/data/entity/user/user_entity.dart +++ b/lib/data/entity/user/user_entity.dart @@ -6,6 +6,7 @@ class UserEntity { final String? birthDate; final String? locale; final String? phone; + final String? createdAt; UserEntity({ required this.id, @@ -15,6 +16,7 @@ class UserEntity { this.birthDate, this.locale, this.phone, + this.createdAt, }); factory UserEntity.fromJson(Map json) { @@ -26,6 +28,7 @@ class UserEntity { birthDate: json['birth_date'], locale: json['locale'], phone: json['phone'], + createdAt: json['created_at'], ); } @@ -37,6 +40,7 @@ class UserEntity { 'pic_url': picUrl, 'birth_date': birthDate, 'locale': locale, + "create_at": createdAt, }; } } diff --git a/lib/data/models/login/login_response_model.dart b/lib/data/models/login/login_response_model.dart index 7d10c6a..df1be0b 100644 --- a/lib/data/models/login/login_response_model.dart +++ b/lib/data/models/login/login_response_model.dart @@ -3,12 +3,12 @@ class LoginResponseModel { final String? googleId; final String email; final String name; - final DateTime? birthDate; + final String? birthDate; final String? picUrl; final String? phone; final String locale; - // final DateTime? createdAt; - // final DateTime? updatedAt; + final String? createdAt; + // final String? updatedAt; LoginResponseModel({ this.id, @@ -19,7 +19,7 @@ class LoginResponseModel { this.picUrl, this.phone, this.locale = "en-US", - // this.createdAt, + this.createdAt, // this.updatedAt, }); @@ -29,12 +29,12 @@ class LoginResponseModel { googleId: json['google_id'], email: json['email'], name: json['name'], - birthDate: json['birth_date'] != null ? DateTime.parse(json['birth_date']) : null, + birthDate: json['birth_date'], picUrl: json['pic_url'], phone: json['phone'], locale: json['locale'] ?? 'en-US', - // createdAt: json['created_at'] != null ? DateTime.parse(json['created_at']) : null, - // updatedAt: json['updated_at'] != null ? DateTime.parse(json['updated_at']) : null, + createdAt: json['created_at'], + // updatedAt: json['updated_at'], ); } @@ -44,12 +44,12 @@ class LoginResponseModel { 'google_id': googleId, 'email': email, 'name': name, - 'birth_date': birthDate?.toIso8601String(), + 'birth_date': birthDate, 'pic_url': picUrl, 'phone': phone, 'locale': locale, - // 'created_at': createdAt?.toIso8601String(), - // 'updated_at': updatedAt?.toIso8601String(), + 'created_at': createdAt, + // 'updated_at': updatedAt, }; } } diff --git a/lib/feature/login/controllers/login_controller.dart b/lib/feature/login/controllers/login_controller.dart index 2e4c0a0..4b0ab43 100644 --- a/lib/feature/login/controllers/login_controller.dart +++ b/lib/feature/login/controllers/login_controller.dart @@ -149,6 +149,9 @@ class LoginController extends GetxController { email: response.email, picUrl: response.picUrl, locale: response.locale, + birthDate: response.birthDate, + createdAt: response.createdAt, + phone: response.phone, ); } } diff --git a/lib/feature/profile/controller/profile_controller.dart b/lib/feature/profile/controller/profile_controller.dart index 042ea38..591c0b9 100644 --- a/lib/feature/profile/controller/profile_controller.dart +++ b/lib/feature/profile/controller/profile_controller.dart @@ -59,7 +59,6 @@ class ProfileController extends GetxController { void onInit() { super.onInit(); loadUserStat(); - // In a real app, you would load these from your API or local storage loadUserProfileData(); } @@ -67,7 +66,9 @@ class ProfileController extends GetxController { try { birthDate.value = _userController.userData?.birthDate ?? ""; phoneNumber.value = _userController.userData?.phone ?? ""; - // joinDate.value = _userController.userData?. ?? ""; + joinDate.value = _userController.userData?.createdAt ?? ""; + + print(_userController.userData!.toJson()); } catch (e, stackTrace) { logC.e("Failed to load user profile data: $e", stackTrace: stackTrace); } diff --git a/lib/feature/profile/view/profile_view.dart b/lib/feature/profile/view/profile_view.dart index 7a6ff10..d05515c 100644 --- a/lib/feature/profile/view/profile_view.dart +++ b/lib/feature/profile/view/profile_view.dart @@ -32,13 +32,13 @@ class ProfileView extends GetView { // const SizedBox(height: 16), // _userHeader(), const SizedBox(height: 24), - _statsCard(context: context, cardRadius: cardRadius), + _statsCard(cardRadius: cardRadius), const SizedBox(height: 10), - _profileDetails(context: context, cardRadius: cardRadius), + _profileDetails(cardRadius: cardRadius), const SizedBox(height: 10), - _settingsSection(context: context, cardRadius: cardRadius), + _settingsSection(cardRadius: cardRadius), const SizedBox(height: 10), - _legalSection(context: context, cardRadius: cardRadius), + _legalSection(cardRadius: cardRadius), const SizedBox(height: 20), ], ), @@ -96,7 +96,7 @@ class ProfileView extends GetView { // ], // ); - Widget _statsCard({required BuildContext context, required BorderRadius cardRadius}) => Card( + Widget _statsCard({required BorderRadius cardRadius}) => Card( color: Colors.white, elevation: 1, shadowColor: AppColors.shadowPrimary, @@ -106,15 +106,15 @@ class ProfileView extends GetView { child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - _statChip(context.tr('total_quiz'), controller.data.value?.totalQuiz.toString() ?? '0'), - _statChip(context.tr('total_solve'), controller.data.value?.totalSolve.toString() ?? '0'), - _statChip(context.tr('avg_score'), '${controller.data.value?.avgScore ?? 100}%'), + _statChip(tr('total_quiz'), controller.data.value?.totalQuiz.toString() ?? '0'), + _statChip(tr('total_solve'), controller.data.value?.totalSolve.toString() ?? '0'), + _statChip(tr('avg_score'), '${controller.data.value?.avgScore ?? 100}%'), ], ), ), ); - Widget _profileDetails({required BuildContext context, required BorderRadius cardRadius}) => Card( + Widget _profileDetails({required BorderRadius cardRadius}) => Card( color: Colors.white, elevation: 1, shadowColor: AppColors.shadowPrimary, @@ -124,44 +124,44 @@ class ProfileView extends GetView { child: Column( children: [ SectionHeader( - title: context.tr('personal_info'), + title: tr('personal_info'), icon: LucideIcons.userCog, onEdit: controller.editProfile, ), const Divider(height: 1), const SizedBox(height: 20), - InfoRow(icon: LucideIcons.user, label: context.tr('full_name'), value: controller.userName.value), + InfoRow(icon: LucideIcons.user, label: tr('full_name'), value: controller.userName.value), InfoRow( icon: LucideIcons.cake, - label: context.tr('birth_date'), - value: controller.birthDate.value ?? context.tr('not_set'), + label: tr('birth_date'), + value: controller.birthDate.value ?? tr('not_set'), ), InfoRow( icon: LucideIcons.phone, - label: context.tr('phone'), - value: controller.phoneNumber.value ?? context.tr('not_set'), - ), - InfoRow( - icon: LucideIcons.mapPin, - label: context.tr('location'), - value: controller.location.value ?? context.tr('not_set'), + label: tr('phone'), + value: controller.phoneNumber.value ?? tr('not_set'), ), + // InfoRow( + // icon: LucideIcons.mapPin, + // label: tr('location'), + // value: controller.location.value ?? tr('not_set'), + // ), InfoRow( icon: LucideIcons.calendar, - label: context.tr('joined'), - value: controller.joinDate.value ?? context.tr('not_available'), - ), - InfoRow( - icon: LucideIcons.graduationCap, - label: context.tr('education'), - value: controller.education.value ?? context.tr('not_set'), + label: tr('joined'), + value: controller.joinDate.value ?? tr('not_available'), ), + // InfoRow( + // icon: LucideIcons.graduationCap, + // label: tr('education'), + // value: controller.education.value ?? tr('not_set'), + // ), ], ), ), ); - Widget _settingsSection({required BuildContext context, required BorderRadius cardRadius}) => Card( + Widget _settingsSection({required BorderRadius cardRadius}) => Card( color: Colors.white, elevation: 1, shadowColor: AppColors.shadowPrimary, @@ -172,18 +172,18 @@ class ProfileView extends GetView { children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 10), - child: SectionHeader(title: context.tr('settings'), icon: LucideIcons.settings), + child: SectionHeader(title: tr('settings'), icon: LucideIcons.settings), ), const Divider(height: 1), - _settingsTile(Get.context!, icon: LucideIcons.languages, title: context.tr('change_language'), onTap: () => _showLanguageDialog(Get.context!)), + _settingsTile(Get.context!, icon: LucideIcons.languages, title: tr('change_language'), onTap: () => _showLanguageDialog(Get.context!)), _settingsTile(Get.context!, - icon: LucideIcons.logOut, title: context.tr('logout'), iconColor: Colors.red, textColor: Colors.red, onTap: controller.logout), + icon: LucideIcons.logOut, title: tr('logout'), iconColor: Colors.red, textColor: Colors.red, onTap: controller.logout), ], ), ), ); - Widget _legalSection({required BuildContext context, required BorderRadius cardRadius}) => Card( + Widget _legalSection({required BorderRadius cardRadius}) => Card( color: Colors.white, elevation: 1, shadowColor: AppColors.shadowPrimary, @@ -194,14 +194,14 @@ class ProfileView extends GetView { children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 10), - child: SectionHeader(title: context.tr('legal_and_support'), icon: LucideIcons.shieldQuestion), + child: SectionHeader(title: tr('legal_and_support'), icon: LucideIcons.shieldQuestion), ), const Divider(height: 1), - _settingsTile(Get.context!, icon: LucideIcons.shield, title: context.tr('privacy_policy'), onTap: controller.openPrivacyPolicy), - _settingsTile(Get.context!, icon: LucideIcons.fileText, title: context.tr('terms_of_service'), onTap: controller.openTermsOfService), - _settingsTile(Get.context!, icon: LucideIcons.helpCircle, title: context.tr('help_center'), onTap: controller.openHelpCenter), - _settingsTile(Get.context!, icon: LucideIcons.mail, title: context.tr('contact_us'), onTap: controller.contactSupport), - _settingsTile(Get.context!, icon: LucideIcons.info, title: context.tr('about_app'), onTap: () => _showAboutAppDialog(Get.context!)), + _settingsTile(Get.context!, icon: LucideIcons.shield, title: tr('privacy_policy'), onTap: controller.openPrivacyPolicy), + _settingsTile(Get.context!, icon: LucideIcons.fileText, title: tr('terms_of_service'), onTap: controller.openTermsOfService), + _settingsTile(Get.context!, icon: LucideIcons.helpCircle, title: tr('help_center'), onTap: controller.openHelpCenter), + _settingsTile(Get.context!, icon: LucideIcons.mail, title: tr('contact_us'), onTap: controller.contactSupport), + _settingsTile(Get.context!, icon: LucideIcons.info, title: tr('about_app'), onTap: () => _showAboutAppDialog(Get.context!)), ], ), ), @@ -211,7 +211,7 @@ class ProfileView extends GetView { showDialog( context: context, builder: (_) => AlertDialog( - title: Text(context.tr('select_language'), style: AppTextStyles.title), + title: Text(tr('select_language'), style: AppTextStyles.title), content: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -255,7 +255,7 @@ class ProfileView extends GetView { const AppName(), const SizedBox(height: 16), Text(controller.appName.value, style: AppTextStyles.title), - Text("${context.tr('version')}: ${controller.appVersion.value}", style: AppTextStyles.caption), + Text("${tr('version')}: ${controller.appVersion.value}", style: AppTextStyles.caption), const SizedBox(height: 16), Text(controller.appDescription.value, textAlign: TextAlign.center, style: AppTextStyles.body), const SizedBox(height: 16), @@ -263,7 +263,7 @@ class ProfileView extends GetView { ], ), actions: [ - TextButton(onPressed: () => Navigator.of(context).pop(), child: Text(context.tr('close'), style: AppTextStyles.optionText)), + TextButton(onPressed: () => Navigator.of(context).pop(), child: Text(tr('close'), style: AppTextStyles.optionText)), ], ), ); From 9a0ccc5c619465b94961b636b118bfde4187533a Mon Sep 17 00:00:00 2001 From: akhdanre Date: Mon, 26 May 2025 12:24:08 +0700 Subject: [PATCH 092/104] feat: adding shimmer loading --- .../widget/container_skeleton_widget.dart | 82 +++++++++++++++++++ .../widget/recomendation_component.dart | 9 +- lib/core/endpoint/api_endpoint.dart | 2 +- lib/feature/history/view/history_view.dart | 9 +- lib/feature/library/view/library_view.dart | 9 +- .../controller/profile_controller.dart | 2 - .../quiz_play/view/quiz_play_view.dart | 1 - pubspec.lock | 8 ++ pubspec.yaml | 1 + 9 files changed, 112 insertions(+), 11 deletions(-) create mode 100644 lib/component/widget/container_skeleton_widget.dart diff --git a/lib/component/widget/container_skeleton_widget.dart b/lib/component/widget/container_skeleton_widget.dart new file mode 100644 index 0000000..39f96aa --- /dev/null +++ b/lib/component/widget/container_skeleton_widget.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:shimmer/shimmer.dart'; + +class ContainerSkeleton extends StatelessWidget { + const ContainerSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + margin: const EdgeInsets.symmetric(vertical: 5), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE1E4E8)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildBox(width: 50, height: 50, borderRadius: 8), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildBox(width: double.infinity, height: 16, borderRadius: 4), + const SizedBox(height: 6), + _buildBox(width: 100, height: 12, borderRadius: 4), + const SizedBox(height: 10), + Row( + children: [ + _buildCircleBox(size: 14), + const SizedBox(width: 6), + _buildBox(width: 60, height: 10, borderRadius: 4), + const SizedBox(width: 12), + _buildCircleBox(size: 14), + const SizedBox(width: 6), + _buildBox(width: 60, height: 10, borderRadius: 4), + ], + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildBox({ + required double width, + required double height, + double borderRadius = 6, + }) { + return Shimmer.fromColors( + baseColor: Colors.grey.shade300, + highlightColor: Colors.grey.shade100, + child: Container( + width: width, + height: height, + decoration: BoxDecoration( + color: Colors.grey, + borderRadius: BorderRadius.circular(borderRadius), + ), + ), + ); + } + + Widget _buildCircleBox({required double size}) { + return Shimmer.fromColors( + baseColor: Colors.grey.shade300, + highlightColor: Colors.grey.shade100, + child: Container( + width: size, + height: size, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.grey, + ), + ), + ); + } +} diff --git a/lib/component/widget/recomendation_component.dart b/lib/component/widget/recomendation_component.dart index c250a6a..203e461 100644 --- a/lib/component/widget/recomendation_component.dart +++ b/lib/component/widget/recomendation_component.dart @@ -1,6 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:quiz_app/component/quiz_container_component.dart'; +import 'package:quiz_app/component/widget/container_skeleton_widget.dart'; import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; class RecomendationComponent extends StatelessWidget { @@ -25,7 +26,6 @@ class RecomendationComponent extends StatelessWidget { _buildSectionTitle(context, title), const SizedBox(height: 10), datas.isNotEmpty - // ? Text("yeay ${datas.length}") ? ListView.builder( shrinkWrap: true, physics: NeverScrollableScrollPhysics(), @@ -35,7 +35,12 @@ class RecomendationComponent extends StatelessWidget { onTap: itemOnTap, ), ) - : SizedBox.shrink() + : ListView.builder( + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + itemCount: 3, + itemBuilder: (context, index) => ContainerSkeleton(), + ) ], ); } diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart index ee5556b..ab82f99 100644 --- a/lib/core/endpoint/api_endpoint.dart +++ b/lib/core/endpoint/api_endpoint.dart @@ -1,5 +1,5 @@ class APIEndpoint { - static const String baseUrl = "http://192.168.1.14:5000"; + static const String baseUrl = "http://192.168.1.18:5000"; // static const String baseUrl = "http://103.193.178.121:5000"; static const String api = "$baseUrl/api"; diff --git a/lib/feature/history/view/history_view.dart b/lib/feature/history/view/history_view.dart index 8e45d24..300787d 100644 --- a/lib/feature/history/view/history_view.dart +++ b/lib/feature/history/view/history_view.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; import 'package:quiz_app/app/const/text/text_style.dart'; -import 'package:quiz_app/component/widget/loading_widget.dart'; +import 'package:quiz_app/component/widget/container_skeleton_widget.dart'; import 'package:quiz_app/data/models/history/quiz_history.dart'; import 'package:quiz_app/feature/history/controller/history_controller.dart'; @@ -29,8 +29,11 @@ class HistoryView extends GetView { const SizedBox(height: 20), Obx(() { if (controller.isLoading.value) { - return const Expanded( - child: Center(child: LoadingWidget()), + return ListView.builder( + itemCount: 3, + itemBuilder: (context, index) { + return ContainerSkeleton(); + }, ); } diff --git a/lib/feature/library/view/library_view.dart b/lib/feature/library/view/library_view.dart index aaeeeb5..97bcf53 100644 --- a/lib/feature/library/view/library_view.dart +++ b/lib/feature/library/view/library_view.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:quiz_app/component/widget/loading_widget.dart'; +import 'package:quiz_app/component/widget/container_skeleton_widget.dart'; import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; import 'package:quiz_app/feature/library/controller/library_controller.dart'; @@ -38,7 +38,12 @@ class LibraryView extends GetView { Expanded( child: Obx(() { if (controller.isLoading.value) { - return LoadingWidget(); + return ListView.builder( + itemCount: 3, + itemBuilder: (context, index) { + return ContainerSkeleton(); + }, + ); } if (controller.quizs.isEmpty) { diff --git a/lib/feature/profile/controller/profile_controller.dart b/lib/feature/profile/controller/profile_controller.dart index 591c0b9..a8cb198 100644 --- a/lib/feature/profile/controller/profile_controller.dart +++ b/lib/feature/profile/controller/profile_controller.dart @@ -67,8 +67,6 @@ class ProfileController extends GetxController { birthDate.value = _userController.userData?.birthDate ?? ""; phoneNumber.value = _userController.userData?.phone ?? ""; joinDate.value = _userController.userData?.createdAt ?? ""; - - print(_userController.userData!.toJson()); } catch (e, stackTrace) { logC.e("Failed to load user profile data: $e", stackTrace: stackTrace); } diff --git a/lib/feature/quiz_play/view/quiz_play_view.dart b/lib/feature/quiz_play/view/quiz_play_view.dart index 9b9b834..a08be3d 100644 --- a/lib/feature/quiz_play/view/quiz_play_view.dart +++ b/lib/feature/quiz_play/view/quiz_play_view.dart @@ -35,7 +35,6 @@ class QuizPlayView extends GetView { _buildQuestionText(), const SizedBox(height: 30), _buildAnswerSection(context), - Spacer(), _buildNextButton(context), ], ); diff --git a/pubspec.lock b/pubspec.lock index f1c0492..a43198a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -525,6 +525,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" + shimmer: + dependency: "direct main" + description: + name: shimmer + sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 8dd467e..6e079a7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,6 +48,7 @@ dependencies: connectivity_plus: ^6.1.4 url_launcher: ^6.3.1 wakelock_plus: ^1.3.2 + shimmer: ^3.0.0 dev_dependencies: flutter_test: From 27a1b9ada66c5a3afc2e37fb12a772ac345cf49a Mon Sep 17 00:00:00 2001 From: akhdanre Date: Mon, 26 May 2025 12:35:15 +0700 Subject: [PATCH 093/104] fix: history skeleton container --- lib/feature/history/view/history_view.dart | 12 +++++++----- lib/feature/search/view/search_view.dart | 15 +++++++-------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/lib/feature/history/view/history_view.dart b/lib/feature/history/view/history_view.dart index 300787d..6394f3a 100644 --- a/lib/feature/history/view/history_view.dart +++ b/lib/feature/history/view/history_view.dart @@ -29,11 +29,13 @@ class HistoryView extends GetView { const SizedBox(height: 20), Obx(() { if (controller.isLoading.value) { - return ListView.builder( - itemCount: 3, - itemBuilder: (context, index) { - return ContainerSkeleton(); - }, + return Expanded( + child: ListView.builder( + itemCount: 3, + itemBuilder: (context, index) { + return ContainerSkeleton(); + }, + ), ); } diff --git a/lib/feature/search/view/search_view.dart b/lib/feature/search/view/search_view.dart index f0ea3f4..c12778f 100644 --- a/lib/feature/search/view/search_view.dart +++ b/lib/feature/search/view/search_view.dart @@ -113,14 +113,13 @@ class SearchView extends GetView { RxList data, VoidCallback onAllTap, ) { - return SizedBox( - height: 410, - child: Obx(() => RecomendationComponent( - title: title, - datas: data.toList(), - itemOnTap: controller.goToDetailPage, - allOnTap: onAllTap, - )), + return Obx( + () => RecomendationComponent( + title: title, + datas: data.toList(), + itemOnTap: controller.goToDetailPage, + allOnTap: onAllTap, + ), ); } } From 655103247e92bfe00c50356101bbd5e58ea81af7 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Mon, 26 May 2025 13:41:09 +0700 Subject: [PATCH 094/104] fix: language --- assets/translations/id-ID.json | 36 +++++++------- assets/translations/ms-MY.json | 91 +++++++++++++++++++--------------- 2 files changed, 70 insertions(+), 57 deletions(-) diff --git a/assets/translations/id-ID.json b/assets/translations/id-ID.json index 1ec381a..57e44e0 100644 --- a/assets/translations/id-ID.json +++ b/assets/translations/id-ID.json @@ -5,7 +5,6 @@ "evening": "Selamat Sore", "night": "Selamat Malam" }, - "greeting_user": "Halo {user}", "create_room": "Buat Ruangan", "join_room": "Gabung Ruang", @@ -17,9 +16,9 @@ "log_in": "Masuk", "sign_in": "Masuk", "email": "Email", - "enter_your_email": "Masukkan Email Anda", + "enter_your_email": "Masukkan Email Kamu", "password": "Kata Sandi", - "enter_your_password": "Masukkan Kata Sandi Anda", + "enter_your_password": "Masukkan Kata Sandi Kamu", "or": "ATAU", "register_title": "Daftar", "full_name": "Nama Lengkap", @@ -34,65 +33,53 @@ "nav_profile": "Profil", "quiz_popular": "Kuis Populer", "see_all": "Lihat Semua", - "library_title": "Pustaka Kuis", "library_description": "Kumpulan pertanyaan kuis untuk belajar.", "no_quiz_available": "Belum ada kuis yang tersedia.", "quiz_count_label": "Kuis", "quiz_count_named": "{total} Kuis", - "history_title": "Riwayat Kuis", "history_subtitle": "Tinjau kuis yang telah kamu kerjakan", "no_history": "Kamu belum memiliki riwayat kuis", "score_label": "Skor: {correct}/{total}", "duration_minutes": "{minute} menit", - "edit_profile": "Edit Profil", "logout": "Keluar", "total_quiz": "Total Kuis", "avg_score": "Skor Rata-rata", - "history_detail_title": "Detail Kuis", "score": "Skor", "time_taken": "Waktu", "duration_seconds": "{second} detik", - "question_type_option": "Pilihan Ganda", "question_type_fill": "Isian Kosong", "question_type_true_false": "Benar / Salah", "question_type_unknown": "Tipe Tidak Dikenal", - "enter_room_code": "Masukkan Kode Ruangan", "room_code_hint": "AB123C", "join_now": "Gabung Sekarang", - "create_quiz_title": "Buat Kuis", "save_all": "Simpan Semua", "mode_generate": "Otomatis", "mode_manual": "Manual", - "quiz_play_title": "Kerjakan Kuis", "ready_in": "Siap dalam {second}", "question_indicator": "Pertanyaan {current} dari {total}", "yes": "Ya", "no": "Tidak", "next": "Berikutnya", - "quiz_preview_title": "Pratinjau Kuis", "quiz_title_label": "Judul", "quiz_description_label": "Deskripsi Singkat", "quiz_subject_label": "Mata Pelajaran", "make_quiz_public": "Jadikan Kuis Publik", "save_quiz": "Simpan Kuis", - "select_language": "Pilih Bahasa", "change_language": "Ganti Bahasa", "auto_generate_quiz": "Buat Kuis Otomatis", - "ready_to_compete": "Siap untuk Bertanding?", "enter_code_to_join": "Masukkan kode kuis dan tunjukkan kemampuanmu!", "join_quiz_now": "Gabung Kuis Sekarang", - "total_solve": "Total Diselesaikan", "personal_info": "Informasi Pribadi", "phone": "Nomor Telepon", @@ -110,14 +97,27 @@ "about_app": "Tentang Aplikasi", "version": "Versi", "close": "Tutup", - "your_answer": "Jawabanmu: {answer}", + "correct": "Benar", "correct_answer": "Jawaban benar: {answer}", "not_answered": "Tidak Menjawab", "seconds_suffix": "d", "quiz_type_option": "Pilihan Ganda", "quiz_type_true_false": "Benar atau Salah", "quiz_type_fill_the_blank": "Isian Kosong", - - "start_quiz": "Start Quiz" + "quiz_detail_title": "Detail Kuis", + "question_label": "Pertanyaan", + "duration_label": "Durasi", + "minutes_suffix": "menit", + "start_quiz": "Start Quiz", + "duration": { + "second": "{} detik", + "minute": "{} menit", + "hour": "{} jam" + }, + "duration_suffix": { + "second": "{} d", + "minute": "{} m", + "hour": "{} j" + } } diff --git a/assets/translations/ms-MY.json b/assets/translations/ms-MY.json index fd50edb..8971c1a 100644 --- a/assets/translations/ms-MY.json +++ b/assets/translations/ms-MY.json @@ -1,24 +1,29 @@ { - "greeting_time": "Selamat Tengah Hari", + "greeting_time": { + "morning": "Selamat Pagi", + "afternoon": "Selamat Tengah Hari", + "evening": "Selamat Petang", + "night": "Selamat Malam" + }, "greeting_user": "Hai {user}", "create_room": "Cipta Bilik", "join_room": "Sertai Bilik", "create_quiz": "Cipta Kuiz", - "ready_new_challenge": "Bersedia untuk cabaran baharu?", - "search_or_select_category": "Cari atau pilih mengikut kategori", + "ready_new_challenge": "Sedia untuk cabaran baru?", + "search_or_select_category": "Cari atau pilih ikut kategori", "search_for_quizzes": "Cari kuiz...", "quiz_recommendation": "Kuiz Disyorkan", "log_in": "Log Masuk", - "sign_in": "Daftar Masuk", - "email": "E-mel", - "enter_your_email": "Masukkan E-mel Anda", + "sign_in": "Log Masuk", + "email": "Emel", + "enter_your_email": "Masukkan Emel Kamu", "password": "Kata Laluan", - "enter_your_password": "Masukkan Kata Laluan Anda", + "enter_your_password": "Masukkan Kata Laluan Kamu", "or": "ATAU", "register_title": "Daftar", "full_name": "Nama Penuh", "birth_date": "Tarikh Lahir", - "phone_optional": "Nombor Telefon (Pilihan)", + "phone_optional": "Nombor Telefon (Opsyenal)", "verify_password": "Sahkan Kata Laluan", "register_button": "Daftar", "nav_home": "Laman Utama", @@ -28,67 +33,58 @@ "nav_profile": "Profil", "quiz_popular": "Kuiz Popular", "see_all": "Lihat Semua", - "library_title": "Perpustakaan Kuiz", - "library_description": "Koleksi soalan kuiz untuk pembelajaran.", - "no_quiz_available": "Tiada kuiz tersedia lagi.", + "library_description": "Koleksi soalan kuiz untuk belajar.", + "no_quiz_available": "Tiada kuiz tersedia buat masa ini.", "quiz_count_label": "Kuiz", "quiz_count_named": "{total} Kuiz", - "history_title": "Sejarah Kuiz", - "history_subtitle": "Semak semula kuiz yang telah anda jawab", - "no_history": "Anda belum mempunyai sejarah kuiz", + "history_subtitle": "Semak semula kuiz yang kamu dah jawab", + "no_history": "Kamu belum ada sejarah kuiz", "score_label": "Skor: {correct}/{total}", "duration_minutes": "{minute} minit", - "edit_profile": "Edit Profil", "logout": "Log Keluar", "total_quiz": "Jumlah Kuiz", "avg_score": "Skor Purata", - "history_detail_title": "Butiran Kuiz", "score": "Skor", - "time_taken": "Masa", + "time_taken": "Masa Diambil", "duration_seconds": "{second} saat", - - "question_type_option": "Pilihan Berganda", - "question_type_fill": "Isi Tempat Kosong", + "question_type_option": "Pilihan Jawapan", + "question_type_fill": "Isian Kosong", "question_type_true_false": "Betul / Salah", "question_type_unknown": "Jenis Tidak Diketahui", - "enter_room_code": "Masukkan Kod Bilik", "room_code_hint": "AB123C", "join_now": "Sertai Sekarang", - "create_quiz_title": "Cipta Kuiz", "save_all": "Simpan Semua", - "mode_generate": "Jana", + "mode_generate": "Auto", "mode_manual": "Manual", - "quiz_play_title": "Jawab Kuiz", - "ready_in": "Bersedia dalam {second}", + "ready_in": "Sedia dalam {second}", "question_indicator": "Soalan {current} daripada {total}", "yes": "Ya", "no": "Tidak", "next": "Seterusnya", - "quiz_preview_title": "Pratonton Kuiz", "quiz_title_label": "Tajuk", "quiz_description_label": "Deskripsi Ringkas", "quiz_subject_label": "Subjek", "make_quiz_public": "Jadikan Kuiz Umum", "save_quiz": "Simpan Kuiz", - - "auto_generate_quiz": "Jana Kuiz Automatik", - "ready_to_compete": "Bersedia untuk Bertanding?", - "enter_code_to_join": "Masukkan kod kuiz dan tunjukkan kemahiran anda!", + "select_language": "Pilih Bahasa", + "change_language": "Tukar Bahasa", + "auto_generate_quiz": "Cipta Kuiz Automatik", + "ready_to_compete": "Sedia untuk Bertanding?", + "enter_code_to_join": "Masukkan kod kuiz dan tunjukkan kebolehan kamu!", "join_quiz_now": "Sertai Kuiz Sekarang", - "total_solve": "Jumlah Diselesaikan", "personal_info": "Maklumat Peribadi", "phone": "Nombor Telefon", "location": "Lokasi", - "joined": "Tarikh Sertai", + "joined": "Mendaftar", "education": "Pendidikan", "not_set": "Belum Ditentukan", "not_available": "Tidak Tersedia", @@ -98,15 +94,32 @@ "terms_of_service": "Terma Perkhidmatan", "help_center": "Pusat Bantuan", "contact_us": "Hubungi Kami", - "about_app": "Mengenai Aplikasi", + "about_app": "Tentang Aplikasi", "version": "Versi", "close": "Tutup", - - "your_answer": "Jawapan anda: {answer}", - "correct_answer": "Jawapan betul: {answer}", - "not_answered": "Tidak Dijawab", - "seconds_suffix": "s", + "your_answer": "Jawapan kamu: {answer}", + "correct": "Betul", + "correct_answer": "Jawapan yang betul: {answer}", + "not_answered": "Belum Dijawab", + "seconds_suffix": "saat", "quiz_type_option": "Pilihan Jawapan", "quiz_type_true_false": "Betul atau Salah", - "quiz_type_fill_the_blank": "Isi Tempat Kosong" + "quiz_type_fill_the_blank": "Isian Kosong", + "quiz_detail_title": "Butiran Kuiz", + "question_label": "Soalan", + "duration_label": "Durasi", + "minutes_suffix": "minit", + "start_quiz": "Mula Kuiz", + + "duration": { + "second": "{} saat", + "minute": "{} minit", + "hour": "{} jam" + }, + + "duration_suffix": { + "second": "{} s", + "minute": "{} m", + "hour": "{} j" + } } From a7f5f98cf5c43a54a2a62dd8eed74672d23d2021 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Mon, 26 May 2025 17:54:16 +0700 Subject: [PATCH 095/104] fix: index restul quiz, translation lang and clean up --- assets/translations/en-US.json | 6 ++- assets/translations/id-ID.json | 5 ++- assets/translations/ms-MY.json | 5 ++- lib/data/services/quiz_service.dart | 9 +++- lib/feature/history/view/history_view.dart | 7 ++- lib/feature/library/view/library_view.dart | 45 ++++++++----------- .../quiz_result/view/quiz_result_view.dart | 2 +- 7 files changed, 44 insertions(+), 35 deletions(-) diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index b5831d8..541d554 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -135,5 +135,9 @@ "second": "{} s", "minute": "{} m", "hour": "{} h" - } + }, + + "get_ready": "Get Ready", + "quiz_starting_soon" : "Quiz Starting Soon" + } diff --git a/assets/translations/id-ID.json b/assets/translations/id-ID.json index 57e44e0..ccd48c8 100644 --- a/assets/translations/id-ID.json +++ b/assets/translations/id-ID.json @@ -119,5 +119,8 @@ "second": "{} d", "minute": "{} m", "hour": "{} j" - } + }, + + "get_ready": "Bersiaplah", + "quiz_starting_soon": "Kuis akan segera dimulai" } diff --git a/assets/translations/ms-MY.json b/assets/translations/ms-MY.json index 8971c1a..0c29ec8 100644 --- a/assets/translations/ms-MY.json +++ b/assets/translations/ms-MY.json @@ -121,5 +121,8 @@ "second": "{} s", "minute": "{} m", "hour": "{} j" - } + }, + + "get_ready": "Bersedia", + "quiz_starting_soon": "Kuiz akan bermula sebentar lagi" } diff --git a/lib/data/services/quiz_service.dart b/lib/data/services/quiz_service.dart index 14eaed0..b0b2bf1 100644 --- a/lib/data/services/quiz_service.dart +++ b/lib/data/services/quiz_service.dart @@ -1,4 +1,7 @@ +import 'dart:ui'; + import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/core/endpoint/api_endpoint.dart'; import 'package:quiz_app/core/utils/logger.dart'; @@ -82,7 +85,8 @@ class QuizService extends GetxService { Future>?> populerQuiz({int page = 1, int amount = 3}) async { try { - final response = await dio.get("${APIEndpoint.quizPopuler}?page=$page&limit=$amount"); + Locale locale = Localizations.localeOf(Get.context!); + final response = await dio.get("${APIEndpoint.quizPopuler}?page=$page&limit=$amount&lang_code=${locale.languageCode}"); if (response.statusCode == 200) { final parsedResponse = BaseResponseModel>.fromJson( @@ -102,7 +106,8 @@ class QuizService extends GetxService { Future>?> recommendationQuiz({int page = 1, int amount = 3, String userId = ""}) async { try { - final response = await dio.get("${APIEndpoint.quizRecommendation}?page=$page&limit=$amount&user_id$userId"); + Locale locale = Localizations.localeOf(Get.context!); + final response = await dio.get("${APIEndpoint.quizRecommendation}?page=$page&limit=$amount&user_id$userId&lang_code=${locale.languageCode}"); if (response.statusCode == 200) { final parsedResponse = BaseResponseModel>.fromJson( diff --git a/lib/feature/history/view/history_view.dart b/lib/feature/history/view/history_view.dart index 6394f3a..d911452 100644 --- a/lib/feature/history/view/history_view.dart +++ b/lib/feature/history/view/history_view.dart @@ -16,11 +16,14 @@ class HistoryView extends GetView { backgroundColor: AppColors.background, body: SafeArea( child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(context.tr("history_title"), style: AppTextStyles.title.copyWith(fontSize: 24)), + Text( + context.tr("history_title"), + style: AppTextStyles.title.copyWith(fontSize: 24), + ), const SizedBox(height: 8), Text( context.tr("history_subtitle"), diff --git a/lib/feature/library/view/library_view.dart b/lib/feature/library/view/library_view.dart index 97bcf53..bfb597b 100644 --- a/lib/feature/library/view/library_view.dart +++ b/lib/feature/library/view/library_view.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:quiz_app/app/const/text/text_style.dart'; import 'package:quiz_app/component/widget/container_skeleton_widget.dart'; import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; import 'package:quiz_app/feature/library/controller/library_controller.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; class LibraryView extends GetView { const LibraryView({super.key}); @@ -11,7 +13,7 @@ class LibraryView extends GetView { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: const Color(0xFFF9FAFB), + backgroundColor: AppColors.background2, body: SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), @@ -20,19 +22,12 @@ class LibraryView extends GetView { children: [ Text( context.tr('library_title'), - style: const TextStyle( - color: Colors.black, - fontWeight: FontWeight.bold, - fontSize: 24, - ), + style: AppTextStyles.title.copyWith(fontSize: 24), ), const SizedBox(height: 8), Text( context.tr('library_description'), - style: const TextStyle( - color: Colors.grey, - fontSize: 14, - ), + style: AppTextStyles.subtitle, ), const SizedBox(height: 20), Expanded( @@ -50,7 +45,7 @@ class LibraryView extends GetView { return Center( child: Text( context.tr('no_quiz_available'), - style: const TextStyle(color: Colors.grey, fontSize: 14), + style: AppTextStyles.caption, ), ); } @@ -79,7 +74,7 @@ class LibraryView extends GetView { margin: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.white, + color: AppColors.background, borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( @@ -95,7 +90,7 @@ class LibraryView extends GetView { width: 48, height: 48, decoration: BoxDecoration( - color: const Color(0xFF2563EB), + color: AppColors.primaryBlue, borderRadius: BorderRadius.circular(12), ), child: const Icon(Icons.menu_book_rounded, color: Colors.white), @@ -107,46 +102,42 @@ class LibraryView extends GetView { children: [ Text( quiz.title, - style: const TextStyle( + style: AppTextStyles.body.copyWith( fontWeight: FontWeight.bold, fontSize: 16, - color: Colors.black, - overflow: TextOverflow.ellipsis, ), maxLines: 1, + overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), Text( quiz.description, - style: const TextStyle( - color: Colors.grey, - fontSize: 12, - overflow: TextOverflow.ellipsis, - ), + style: AppTextStyles.caption, maxLines: 2, + overflow: TextOverflow.ellipsis, ), const SizedBox(height: 8), Row( children: [ - const Icon(Icons.calendar_today_rounded, size: 14, color: Colors.grey), + const Icon(Icons.calendar_today_rounded, size: 14, color: AppColors.softGrayText), const SizedBox(width: 4), Text( controller.formatDate(quiz.date), - style: const TextStyle(fontSize: 12, color: Colors.grey), + style: AppTextStyles.dateTime, ), const SizedBox(width: 12), - const Icon(Icons.list, size: 14, color: Colors.grey), + const Icon(Icons.list, size: 14, color: AppColors.softGrayText), const SizedBox(width: 4), Text( context.tr('quiz_count_named', namedArgs: {'total': quiz.totalQuiz.toString()}), - style: const TextStyle(fontSize: 12, color: Colors.grey), + style: AppTextStyles.dateTime, ), const SizedBox(width: 12), - const Icon(Icons.access_time, size: 14, color: Colors.grey), + const Icon(Icons.access_time, size: 14, color: AppColors.softGrayText), const SizedBox(width: 4), Text( controller.formatDuration(quiz.duration), - style: const TextStyle(fontSize: 12, color: Colors.grey), + style: AppTextStyles.dateTime, ), ], ), diff --git a/lib/feature/quiz_result/view/quiz_result_view.dart b/lib/feature/quiz_result/view/quiz_result_view.dart index 63ca944..e72bf76 100644 --- a/lib/feature/quiz_result/view/quiz_result_view.dart +++ b/lib/feature/quiz_result/view/quiz_result_view.dart @@ -95,7 +95,7 @@ class QuizResultView extends GetView { final parsed = _parseAnswer(question, answer.selectedAnswer); return QuizItemWAComponent( - index: index, + index: question.index, isCorrect: answer.isCorrect, question: question.question, targetAnswer: parsed.targetAnswer, From 0283806cf3156e72d396d0433d74d94f61ba7063 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Mon, 26 May 2025 19:05:47 +0700 Subject: [PATCH 096/104] fix: waiting room localization and adding room name --- assets/translations/en-US.json | 14 +++- assets/translations/id-ID.json | 14 +++- assets/translations/ms-MY.json | 14 +++- lib/app/const/text/string_extension.dart | 5 ++ .../models/session/session_info_model.dart | 3 + .../models/session/session_request_model.dart | 4 ++ lib/data/services/session_service.dart | 6 +- .../join_room/view/join_room_view.dart | 52 +++++++------- .../controller/room_maker_controller.dart | 1 + .../controller/waiting_room_controller.dart | 2 + .../waiting_room/view/waiting_room_view.dart | 69 ++++++++++++------- 11 files changed, 125 insertions(+), 59 deletions(-) create mode 100644 lib/app/const/text/string_extension.dart diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index 541d554..c0cf4ee 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -138,6 +138,18 @@ }, "get_ready": "Get Ready", - "quiz_starting_soon" : "Quiz Starting Soon" + "quiz_starting_soon": "Quiz Starting Soon", + "waiting_room": { + "title": "Waiting Room", + "participants_joined": "Participants Joined:", + "leave_room": "Leave Room", + "session_code": "Session Code:", + "copy_code": "Copy Code", + "quiz_info": "Quiz Information:", + "quiz_title": "Title", + "quiz_description": "Description", + "quiz_total_question": "Total Questions", + "quiz_duration": "Duration" + } } diff --git a/assets/translations/id-ID.json b/assets/translations/id-ID.json index ccd48c8..8b829f1 100644 --- a/assets/translations/id-ID.json +++ b/assets/translations/id-ID.json @@ -122,5 +122,17 @@ }, "get_ready": "Bersiaplah", - "quiz_starting_soon": "Kuis akan segera dimulai" + "quiz_starting_soon": "Kuis akan segera dimulai", + "waiting_room": { + "title": "Ruang Tunggu", + "participants_joined": "Peserta Bergabung:", + "leave_room": "Keluar dari Ruangan", + "session_code": "Kode Sesi:", + "copy_code": "Salin Kode", + "quiz_info": "Informasi Kuis:", + "quiz_title": "Judul", + "quiz_description": "Deskripsi", + "quiz_total_question": "Total Pertanyaan", + "quiz_duration": "Durasi" + } } diff --git a/assets/translations/ms-MY.json b/assets/translations/ms-MY.json index 0c29ec8..8a4b48c 100644 --- a/assets/translations/ms-MY.json +++ b/assets/translations/ms-MY.json @@ -124,5 +124,17 @@ }, "get_ready": "Bersedia", - "quiz_starting_soon": "Kuiz akan bermula sebentar lagi" + "quiz_starting_soon": "Kuiz akan bermula sebentar lagi", + "waiting_room": { + "title": "Bilik Menunggu", + "participants_joined": "Peserta Telah Sertai:", + "leave_room": "Tinggalkan Bilik", + "session_code": "Kod Sesi:", + "copy_code": "Salin Kod", + "quiz_info": "Maklumat Kuiz:", + "quiz_title": "Tajuk", + "quiz_description": "Penerangan", + "quiz_total_question": "Jumlah Soalan", + "quiz_duration": "Tempoh" + } } diff --git a/lib/app/const/text/string_extension.dart b/lib/app/const/text/string_extension.dart new file mode 100644 index 0000000..060a04f --- /dev/null +++ b/lib/app/const/text/string_extension.dart @@ -0,0 +1,5 @@ +extension StringCasingExtension on String { + String toTitleCase() { + return split(' ').map((word) => word.isNotEmpty ? '${word[0].toUpperCase()}${word.substring(1).toLowerCase()}' : '').join(' '); + } +} diff --git a/lib/data/models/session/session_info_model.dart b/lib/data/models/session/session_info_model.dart index cca5d1d..db7e3de 100644 --- a/lib/data/models/session/session_info_model.dart +++ b/lib/data/models/session/session_info_model.dart @@ -3,6 +3,7 @@ import 'package:quiz_app/data/models/user/user_model.dart'; class SessionInfo { final String id; final String sessionCode; + final String roomName; final String quizId; final String hostId; final DateTime createdAt; @@ -16,6 +17,7 @@ class SessionInfo { SessionInfo({ required this.id, required this.sessionCode, + required this.roomName, required this.quizId, required this.hostId, required this.createdAt, @@ -31,6 +33,7 @@ class SessionInfo { return SessionInfo( id: json['id'], sessionCode: json['session_code'], + roomName: json["room_name"], quizId: json['quiz_id'], hostId: json['host_id'], createdAt: DateTime.parse(json['created_at']), diff --git a/lib/data/models/session/session_request_model.dart b/lib/data/models/session/session_request_model.dart index 6eced2f..2e0dfc8 100644 --- a/lib/data/models/session/session_request_model.dart +++ b/lib/data/models/session/session_request_model.dart @@ -1,11 +1,13 @@ class SessionRequestModel { final String quizId; final String hostId; + final String roomName; final int limitParticipan; SessionRequestModel({ required this.quizId, required this.hostId, + required this.roomName, required this.limitParticipan, }); @@ -13,6 +15,7 @@ class SessionRequestModel { return SessionRequestModel( quizId: json['quiz_id'], hostId: json['host_id'], + roomName: json['room_name'], limitParticipan: json['limit_participan'], ); } @@ -21,6 +24,7 @@ class SessionRequestModel { return { 'quiz_id': quizId, 'host_id': hostId, + 'room_name': roomName, 'limit_participan': limitParticipan, }; } diff --git a/lib/data/services/session_service.dart b/lib/data/services/session_service.dart index 5e19015..0759009 100644 --- a/lib/data/services/session_service.dart +++ b/lib/data/services/session_service.dart @@ -17,11 +17,7 @@ class SessionService extends GetxService { Future?> createSession(SessionRequestModel data) async { try { - final response = await _dio.post(APIEndpoint.session, data: { - 'quiz_id': data.quizId, - 'host_id': data.hostId, - 'limit_participan': data.limitParticipan, - }); + final response = await _dio.post(APIEndpoint.session, data: data.toJson()); if (response.statusCode != 201) { return null; } diff --git a/lib/feature/join_room/view/join_room_view.dart b/lib/feature/join_room/view/join_room_view.dart index 3fa9f69..ffcf3ea 100644 --- a/lib/feature/join_room/view/join_room_view.dart +++ b/lib/feature/join_room/view/join_room_view.dart @@ -33,32 +33,32 @@ class JoinRoomView extends GetView { children: [ const SizedBox(height: 20), - TweenAnimationBuilder( - duration: const Duration(seconds: 1), - tween: Tween(begin: 0.0, end: 1.0), - builder: (context, value, child) { - return Transform.scale( - scale: value, - child: child, - ); - }, - child: Container( - padding: EdgeInsets.all(22), - decoration: BoxDecoration( - color: AppColors.primaryBlue.withValues(alpha: 0.05), - shape: BoxShape.circle, - border: Border.all( - color: AppColors.primaryBlue.withValues(alpha: 0.15), - width: 2, - ), - ), - child: Icon( - LucideIcons.trophy, - size: 70, - color: AppColors.primaryBlue, - ), - ), - ), + // TweenAnimationBuilder( + // duration: const Duration(seconds: 1), + // tween: Tween(begin: 0.0, end: 1.0), + // builder: (context, value, child) { + // return Transform.scale( + // scale: value, + // child: child, + // ); + // }, + // child: Container( + // padding: EdgeInsets.all(22), + // decoration: BoxDecoration( + // color: AppColors.primaryBlue.withValues(alpha: 0.05), + // shape: BoxShape.circle, + // border: Border.all( + // color: AppColors.primaryBlue.withValues(alpha: 0.15), + // width: 2, + // ), + // ), + // child: Icon( + // LucideIcons.trophy, + // size: 70, + // color: AppColors.primaryBlue, + // ), + // ), + // ), const SizedBox(height: 30), diff --git a/lib/feature/room_maker/controller/room_maker_controller.dart b/lib/feature/room_maker/controller/room_maker_controller.dart index 3ace6f1..e0b0fdc 100644 --- a/lib/feature/room_maker/controller/room_maker_controller.dart +++ b/lib/feature/room_maker/controller/room_maker_controller.dart @@ -103,6 +103,7 @@ class RoomMakerController extends GetxController { SessionRequestModel( quizId: quiz.quizId, hostId: _userController.userData!.id, + roomName: nameTC.text, limitParticipan: int.parse(maxPlayerTC.text), ), ); diff --git a/lib/feature/waiting_room/controller/waiting_room_controller.dart b/lib/feature/waiting_room/controller/waiting_room_controller.dart index 2ec10e9..b18ace9 100644 --- a/lib/feature/waiting_room/controller/waiting_room_controller.dart +++ b/lib/feature/waiting_room/controller/waiting_room_controller.dart @@ -16,6 +16,7 @@ class WaitingRoomController extends GetxController { WaitingRoomController(this._socketService, this._userController); final sessionCode = ''.obs; + final roomName = "".obs; String sessionId = ''; final quizMeta = Rx(null); final joinedUsers = [].obs; @@ -42,6 +43,7 @@ class WaitingRoomController extends GetxController { sessionId = roomData!.sessionId; quizMeta.value = data.quizInfo; + roomName.value = data.sessionInfo.roomName; joinedUsers.assignAll(data.sessionInfo.participants); } diff --git a/lib/feature/waiting_room/view/waiting_room_view.dart b/lib/feature/waiting_room/view/waiting_room_view.dart index 860110a..7eaa603 100644 --- a/lib/feature/waiting_room/view/waiting_room_view.dart +++ b/lib/feature/waiting_room/view/waiting_room_view.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:quiz_app/app/const/text/string_extension.dart'; +import 'package:quiz_app/app/const/text/text_style.dart'; import 'package:quiz_app/component/global_button.dart'; import 'package:quiz_app/data/models/quiz/quiz_info_model.dart'; import 'package:quiz_app/data/models/user/user_model.dart'; @@ -11,7 +14,9 @@ class WaitingRoomView extends GetView { Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.background, - appBar: AppBar(title: const Text("Waiting Room")), + appBar: AppBar( + title: Text(tr("waiting_room.title"), style: AppTextStyles.title), + ), body: Padding( padding: const EdgeInsets.all(16.0), child: Obx(() { @@ -22,25 +27,39 @@ class WaitingRoomView extends GetView { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + const SizedBox(height: 20), + Center( + child: Obx(() => Text( + controller.roomName.value.toTitleCase(), + style: AppTextStyles.title, + )), + ), + const SizedBox(height: 20), _buildQuizMeta(quiz!), const SizedBox(height: 20), _buildSessionCode(context, session), const SizedBox(height: 20), - const Text("Peserta yang Bergabung:", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + Text( + tr("waiting_room.participants_joined"), + style: AppTextStyles.subtitle.copyWith( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.darkText, + ), + ), const SizedBox(height: 10), Expanded(child: Obx(() => _buildUserList(users.toList()))), const SizedBox(height: 16), - if (controller.isAdmin.value) - GlobalButton( - text: "Mulai Kuis", - onPressed: controller.startQuiz, - ) - else - GlobalButton( - text: "Tinggalkan Ruangan", - onPressed: controller.leaveRoom, - baseColor: const Color.fromARGB(255, 204, 14, 0), - ) + controller.isAdmin.value + ? GlobalButton( + text: tr("start_quiz"), + onPressed: controller.startQuiz, + ) + : GlobalButton( + text: tr("waiting_room.leave_room"), + onPressed: controller.leaveRoom, + baseColor: const Color.fromARGB(255, 204, 14, 0), + ) ], ); }), @@ -52,18 +71,19 @@ class WaitingRoomView extends GetView { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: AppColors.primaryBlue.withValues(alpha: 0.05), + color: AppColors.accentBlue.withOpacity(0.1), borderRadius: BorderRadius.circular(8), border: Border.all(color: AppColors.primaryBlue), ), child: Row( children: [ - const Text("Session Code: ", style: TextStyle(fontWeight: FontWeight.bold)), - SelectableText(code, style: const TextStyle(fontSize: 16)), + Text(tr("waiting_room.session_code"), style: AppTextStyles.statValue), + const SizedBox(width: 4), + SelectableText(code, style: AppTextStyles.body.copyWith(fontSize: 16)), const Spacer(), IconButton( icon: const Icon(Icons.copy), - tooltip: 'Salin Kode', + tooltip: tr("waiting_room.copy_code"), onPressed: () => controller.copySessionCode(context), ), ], @@ -72,7 +92,6 @@ class WaitingRoomView extends GetView { } Widget _buildQuizMeta(QuizInfo quiz) { - // if (quiz == null) return const SizedBox.shrink(); return Container( padding: const EdgeInsets.all(16), width: double.infinity, @@ -84,12 +103,12 @@ class WaitingRoomView extends GetView { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text("Informasi Kuis:", style: TextStyle(fontWeight: FontWeight.bold)), + Text(tr("waiting_room.quiz_info"), style: AppTextStyles.subtitle.copyWith(fontWeight: FontWeight.bold)), const SizedBox(height: 8), - Text("Judul: ${quiz.title}"), - Text("Deskripsi: ${quiz.description}"), - Text("Jumlah Soal: ${quiz.totalQuiz}"), - Text("Durasi: ${quiz.limitDuration ~/ 60} menit"), + Text("${tr("waiting_room.quiz_title")}: ${quiz.title}", style: AppTextStyles.body), + Text("${tr("waiting_room.quiz_description")}: ${quiz.description}", style: AppTextStyles.body), + Text("${tr("waiting_room.quiz_total_question")}: ${quiz.totalQuiz}", style: AppTextStyles.body), + Text("${tr("waiting_room.quiz_duration")}: ${quiz.limitDuration ~/ 60} min", style: AppTextStyles.body), ], ), ); @@ -110,9 +129,9 @@ class WaitingRoomView extends GetView { ), child: Row( children: [ - CircleAvatar(child: Text(user.username[0])), + CircleAvatar(child: Text(user.username[0].toUpperCase())), const SizedBox(width: 12), - Text(user.username, style: const TextStyle(fontSize: 16)), + Text(user.username, style: AppTextStyles.body.copyWith(fontSize: 16)), ], ), ); From b77229c26a4af8633e9eec5ca25821253023b804 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Tue, 27 May 2025 19:04:19 +0700 Subject: [PATCH 097/104] fix: adding adjustment on the login app --- lib/app/app.dart | 2 +- lib/core/endpoint/api_endpoint.dart | 4 ++-- lib/data/services/quiz_service.dart | 2 +- .../login/controllers/login_controller.dart | 14 ++++++++++---- lib/main.dart | 3 ++- 5 files changed, 16 insertions(+), 9 deletions(-) diff --git a/lib/app/app.dart b/lib/app/app.dart index da3b46f..0714483 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -12,7 +12,7 @@ class MyApp extends StatelessWidget { return GetMaterialApp( title: 'Quiz App', locale: Get.locale ?? context.locale, - fallbackLocale: const Locale('en', 'US'), + fallbackLocale: const Locale('id', 'ID'), localizationsDelegates: context.localizationDelegates, supportedLocales: context.supportedLocales, initialBinding: InitialBindings(), diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart index ab82f99..9f01866 100644 --- a/lib/core/endpoint/api_endpoint.dart +++ b/lib/core/endpoint/api_endpoint.dart @@ -1,6 +1,6 @@ class APIEndpoint { - static const String baseUrl = "http://192.168.1.18:5000"; - // static const String baseUrl = "http://103.193.178.121:5000"; + // static const String baseUrl = "http://172.16.106.19:5000"; + static const String baseUrl = "http://103.193.178.121:5000"; static const String api = "$baseUrl/api"; static const String login = "/login"; diff --git a/lib/data/services/quiz_service.dart b/lib/data/services/quiz_service.dart index b0b2bf1..f62a8e0 100644 --- a/lib/data/services/quiz_service.dart +++ b/lib/data/services/quiz_service.dart @@ -107,7 +107,7 @@ class QuizService extends GetxService { Future>?> recommendationQuiz({int page = 1, int amount = 3, String userId = ""}) async { try { Locale locale = Localizations.localeOf(Get.context!); - final response = await dio.get("${APIEndpoint.quizRecommendation}?page=$page&limit=$amount&user_id$userId&lang_code=${locale.languageCode}"); + final response = await dio.get("${APIEndpoint.quizRecommendation}?page=$page&limit=$amount&user_id=$userId&lang_code=${locale.languageCode}"); if (response.statusCode == 200) { final parsedResponse = BaseResponseModel>.fromJson( diff --git a/lib/feature/login/controllers/login_controller.dart b/lib/feature/login/controllers/login_controller.dart index 4b0ab43..584d6be 100644 --- a/lib/feature/login/controllers/login_controller.dart +++ b/lib/feature/login/controllers/login_controller.dart @@ -3,6 +3,7 @@ import 'package:get/get.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/component/global_button.dart'; import 'package:quiz_app/core/helper/connection_check.dart'; +import 'package:quiz_app/core/utils/custom_floating_loading.dart'; import 'package:quiz_app/core/utils/custom_notification.dart'; import 'package:quiz_app/core/utils/logger.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; @@ -87,6 +88,7 @@ class LoginController extends GetxController { } try { isLoading.value = true; + CustomFloatingLoading.showLoadingDialog(Get.context!); final LoginResponseModel response = await _authService.loginWithEmail( LoginRequestModel(email: email, password: password), @@ -97,13 +99,12 @@ class LoginController extends GetxController { await _userStorageService.saveUser(userEntity); _userController.setUserFromEntity(userEntity); _userStorageService.isLogged = true; - + CustomFloatingLoading.hideLoadingDialog(Get.context!); Get.offAllNamed(AppRoutes.mainPage); } catch (e, stackTrace) { logC.e(e, stackTrace: stackTrace); + CustomFloatingLoading.hideLoadingDialog(Get.context!); CustomNotification.error(title: "Gagal", message: "Periksa kembali email dan kata sandi Anda"); - } finally { - isLoading.value = false; } } @@ -113,15 +114,20 @@ class LoginController extends GetxController { return; } try { + CustomFloatingLoading.showLoadingDialog(Get.context!); final user = await _googleAuthService.signIn(); if (user == null) { Get.snackbar("Kesalahan", "Masuk dengan Google dibatalkan"); + + CustomFloatingLoading.hideLoadingDialog(Get.context!); return; } final idToken = await user.authentication.then((auth) => auth.idToken); if (idToken == null || idToken.isEmpty) { Get.snackbar("Kesalahan", "Tidak menerima ID Token dari Google"); + + CustomFloatingLoading.hideLoadingDialog(Get.context!); return; } @@ -131,7 +137,7 @@ class LoginController extends GetxController { await _userStorageService.saveUser(userEntity); _userController.setUserFromEntity(userEntity); _userStorageService.isLogged = true; - + CustomFloatingLoading.hideLoadingDialog(Get.context!); Get.offAllNamed(AppRoutes.mainPage); } catch (e, stackTrace) { logC.e("Google Sign-In Error: $e", stackTrace: stackTrace); diff --git a/lib/main.dart b/lib/main.dart index 11c14fb..35ae197 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -29,7 +29,8 @@ void main() { Locale('ms', 'MY'), ], path: 'assets/translations', - fallbackLocale: Locale('en', 'US'), + fallbackLocale: Locale('id', 'ID'), + startLocale: Locale('id', 'ID'), useOnlyLangCode: false, child: MyApp(), ), From ede385041e59c3b4f249fa0b5fd232cee6491a7e Mon Sep 17 00:00:00 2001 From: akhdanre Date: Thu, 29 May 2025 15:59:13 +0700 Subject: [PATCH 098/104] feat: adjustement on the serveral part on the profile and quiz result --- assets/translations/en-US.json | 4 +- assets/translations/id-ID.json | 4 +- assets/translations/ms-MY.json | 3 +- .../notification/pop_up_confirmation.dart | 84 +++++++++++++++++++ lib/core/endpoint/api_endpoint.dart | 4 +- lib/data/entity/user/user_entity.dart | 1 + .../login/controllers/login_controller.dart | 2 +- .../controller/profile_controller.dart | 30 ++++--- lib/feature/profile/view/profile_view.dart | 6 +- .../profile/view/update_profile_view.dart | 6 +- .../quiz_result/view/quiz_result_view.dart | 2 +- 11 files changed, 125 insertions(+), 21 deletions(-) diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index c0cf4ee..ef0aed4 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -151,5 +151,7 @@ "quiz_description": "Description", "quiz_total_question": "Total Questions", "quiz_duration": "Duration" - } + }, + + "save_changes" : "Save Changes" } diff --git a/assets/translations/id-ID.json b/assets/translations/id-ID.json index 8b829f1..2413acc 100644 --- a/assets/translations/id-ID.json +++ b/assets/translations/id-ID.json @@ -134,5 +134,7 @@ "quiz_description": "Deskripsi", "quiz_total_question": "Total Pertanyaan", "quiz_duration": "Durasi" - } + }, + "save_changes": "Simpan Perubahan" + } diff --git a/assets/translations/ms-MY.json b/assets/translations/ms-MY.json index 8a4b48c..31bbb63 100644 --- a/assets/translations/ms-MY.json +++ b/assets/translations/ms-MY.json @@ -136,5 +136,6 @@ "quiz_description": "Penerangan", "quiz_total_question": "Jumlah Soalan", "quiz_duration": "Tempoh" - } + }, + "save_changes": "Simpan Perubahan" } diff --git a/lib/component/notification/pop_up_confirmation.dart b/lib/component/notification/pop_up_confirmation.dart index 76fc147..46cbe7e 100644 --- a/lib/component/notification/pop_up_confirmation.dart +++ b/lib/component/notification/pop_up_confirmation.dart @@ -119,4 +119,88 @@ class AppDialog { }, ); } + + static Future showConfirmationDialog( + BuildContext context, { + required String title, + required String message, + String cancelText = "Batal", + String confirmText = "Yakin", + Color confirmColor = AppColors.primaryBlue, + }) async { + return showDialog( + context: context, + barrierDismissible: true, + builder: (BuildContext context) { + return Dialog( + backgroundColor: AppColors.background, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.darkText, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + message, + style: const TextStyle( + fontSize: 14, + color: AppColors.softGrayText, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: () => Navigator.pop(context, false), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: AppColors.primaryBlue, + side: const BorderSide(color: AppColors.primaryBlue), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 14), + ), + child: Text(cancelText), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom( + backgroundColor: confirmColor, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 14), + ), + child: Text(confirmText), + ), + ), + ], + ) + ], + ), + ), + ); + }, + ); + } } diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart index 9f01866..ab82f99 100644 --- a/lib/core/endpoint/api_endpoint.dart +++ b/lib/core/endpoint/api_endpoint.dart @@ -1,6 +1,6 @@ class APIEndpoint { - // static const String baseUrl = "http://172.16.106.19:5000"; - static const String baseUrl = "http://103.193.178.121:5000"; + static const String baseUrl = "http://192.168.1.18:5000"; + // static const String baseUrl = "http://103.193.178.121:5000"; static const String api = "$baseUrl/api"; static const String login = "/login"; diff --git a/lib/data/entity/user/user_entity.dart b/lib/data/entity/user/user_entity.dart index f98fb41..d147830 100644 --- a/lib/data/entity/user/user_entity.dart +++ b/lib/data/entity/user/user_entity.dart @@ -40,6 +40,7 @@ class UserEntity { 'pic_url': picUrl, 'birth_date': birthDate, 'locale': locale, + 'phone': phone, "create_at": createdAt, }; } diff --git a/lib/feature/login/controllers/login_controller.dart b/lib/feature/login/controllers/login_controller.dart index 584d6be..8dbcaad 100644 --- a/lib/feature/login/controllers/login_controller.dart +++ b/lib/feature/login/controllers/login_controller.dart @@ -148,7 +148,7 @@ class LoginController extends GetxController { void goToRegsPage() => Get.toNamed(AppRoutes.registerPage); UserEntity _convertLoginResponseToUserEntity(LoginResponseModel response) { - logC.i("user id : ${response.id}"); + logC.i("user data ${response.toJson()}"); return UserEntity( id: response.id ?? '', name: response.name, diff --git a/lib/feature/profile/controller/profile_controller.dart b/lib/feature/profile/controller/profile_controller.dart index a8cb198..d291820 100644 --- a/lib/feature/profile/controller/profile_controller.dart +++ b/lib/feature/profile/controller/profile_controller.dart @@ -3,6 +3,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/component/notification/pop_up_confirmation.dart'; import 'package:quiz_app/core/endpoint/api_endpoint.dart'; import 'package:quiz_app/core/utils/logger.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; @@ -83,16 +84,25 @@ class ProfileController extends GetxController { } } - void logout() async { - try { - await _googleAuthService.signOut(); - await _userStorageService.clearUser(); - _userController.clearUser(); - _userStorageService.isLogged = false; - Get.offAllNamed(AppRoutes.loginPage); - } catch (e, stackTrace) { - logC.e("Google Sign-Out Error: $e", stackTrace: stackTrace); - Get.snackbar("Error", "Gagal logout dari Google"); + void logout(BuildContext context) async { + final confirm = await AppDialog.showConfirmationDialog( + context, + title: "Keluar dari akun?", + message: "Apakah Anda yakin ingin logout dari akun ini?", + confirmText: "Logout", + ); + + if (confirm == true) { + try { + await _googleAuthService.signOut(); + await _userStorageService.clearUser(); + _userController.clearUser(); + _userStorageService.isLogged = false; + Get.offAllNamed(AppRoutes.loginPage); + } catch (e, stackTrace) { + logC.e("Google Sign-Out Error: $e", stackTrace: stackTrace); + Get.snackbar("Error", "Gagal logout dari Google"); + } } } diff --git a/lib/feature/profile/view/profile_view.dart b/lib/feature/profile/view/profile_view.dart index d05515c..3cf95f1 100644 --- a/lib/feature/profile/view/profile_view.dart +++ b/lib/feature/profile/view/profile_view.dart @@ -36,7 +36,7 @@ class ProfileView extends GetView { const SizedBox(height: 10), _profileDetails(cardRadius: cardRadius), const SizedBox(height: 10), - _settingsSection(cardRadius: cardRadius), + _settingsSection(context, cardRadius: cardRadius), const SizedBox(height: 10), _legalSection(cardRadius: cardRadius), const SizedBox(height: 20), @@ -161,7 +161,7 @@ class ProfileView extends GetView { ), ); - Widget _settingsSection({required BorderRadius cardRadius}) => Card( + Widget _settingsSection(BuildContext context, {required BorderRadius cardRadius}) => Card( color: Colors.white, elevation: 1, shadowColor: AppColors.shadowPrimary, @@ -177,7 +177,7 @@ class ProfileView extends GetView { const Divider(height: 1), _settingsTile(Get.context!, icon: LucideIcons.languages, title: tr('change_language'), onTap: () => _showLanguageDialog(Get.context!)), _settingsTile(Get.context!, - icon: LucideIcons.logOut, title: tr('logout'), iconColor: Colors.red, textColor: Colors.red, onTap: controller.logout), + icon: LucideIcons.logOut, title: tr('logout'), iconColor: Colors.red, textColor: Colors.red, onTap: () => controller.logout(context)), ], ), ), diff --git a/lib/feature/profile/view/update_profile_view.dart b/lib/feature/profile/view/update_profile_view.dart index 3bb4404..8d80650 100644 --- a/lib/feature/profile/view/update_profile_view.dart +++ b/lib/feature/profile/view/update_profile_view.dart @@ -1,5 +1,7 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; import 'package:quiz_app/component/global_button.dart'; import 'package:quiz_app/component/global_dropdown_field.dart'; import 'package:quiz_app/component/global_text_field.dart'; @@ -10,7 +12,9 @@ class UpdateProfilePage extends GetView { @override Widget build(BuildContext context) { return Scaffold( + backgroundColor: AppColors.background2, appBar: AppBar( + backgroundColor: AppColors.background2, title: Text('Update Profile'), centerTitle: true, ), @@ -56,7 +60,7 @@ class UpdateProfilePage extends GetView { )), SizedBox(height: 32), Center( - child: GlobalButton(text: "save_changes", onPressed: controller.saveProfile), + child: GlobalButton(text: tr("save_changes"), onPressed: controller.saveProfile), ), ], ), diff --git a/lib/feature/quiz_result/view/quiz_result_view.dart b/lib/feature/quiz_result/view/quiz_result_view.dart index e72bf76..bbd02d4 100644 --- a/lib/feature/quiz_result/view/quiz_result_view.dart +++ b/lib/feature/quiz_result/view/quiz_result_view.dart @@ -95,7 +95,7 @@ class QuizResultView extends GetView { final parsed = _parseAnswer(question, answer.selectedAnswer); return QuizItemWAComponent( - index: question.index, + index: index + 1, isCorrect: answer.isCorrect, question: question.question, targetAnswer: parsed.targetAnswer, From d5de5fb712289f977bc906ae369f08a59ccf2208 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Thu, 5 Jun 2025 12:43:01 +0700 Subject: [PATCH 099/104] feat: adding limitation on the join and create room --- lib/core/endpoint/api_endpoint.dart | 4 +- lib/core/utils/custom_floating_loading.dart | 29 +++---- lib/data/services/connection_service.dart | 2 +- .../join_room/binding/join_room_binding.dart | 9 ++- .../controller/join_room_controller.dart | 29 ++++--- .../join_room/view/join_room_view.dart | 2 +- .../login/controllers/login_controller.dart | 14 ++-- .../controller/update_profile_controller.dart | 4 +- .../controller/quiz_creation_controller.dart | 4 +- .../controller/quiz_preview_controller.dart | 4 +- .../controller/register_controller.dart | 6 +- .../binding/room_maker_binding.dart | 2 + .../controller/room_maker_controller.dart | 18 ++++- .../room_maker/view/room_maker_view.dart | 76 +++++++++---------- 14 files changed, 114 insertions(+), 89 deletions(-) diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart index ab82f99..2cbebcd 100644 --- a/lib/core/endpoint/api_endpoint.dart +++ b/lib/core/endpoint/api_endpoint.dart @@ -1,6 +1,6 @@ class APIEndpoint { - static const String baseUrl = "http://192.168.1.18:5000"; - // static const String baseUrl = "http://103.193.178.121:5000"; + // static const String baseUrl = "http://192.168.107.43:5000"; + static const String baseUrl = "http://103.193.178.121:5000"; static const String api = "$baseUrl/api"; static const String login = "/login"; diff --git a/lib/core/utils/custom_floating_loading.dart b/lib/core/utils/custom_floating_loading.dart index 2ac487d..6b1a581 100644 --- a/lib/core/utils/custom_floating_loading.dart +++ b/lib/core/utils/custom_floating_loading.dart @@ -1,23 +1,24 @@ import 'package:flutter/material.dart'; +import 'package:get/get.dart'; class CustomFloatingLoading { - static void showLoadingDialog(BuildContext context) { - showDialog( - context: context, + static const String _dialogId = 'custom_loading'; + + static void showLoadingDialog() { + Get.dialog( + PopScope( + canPop: false, + child: const Center(child: CircularProgressIndicator()), + ), barrierDismissible: false, - barrierColor: Colors.black.withValues(alpha: 0.3), - builder: (BuildContext context) { - return PopScope( - canPop: false, - child: const Center( - child: CircularProgressIndicator(), - ), - ); - }, + barrierColor: Colors.black.withAlpha(76), + name: _dialogId, ); } - static void hideLoadingDialog(BuildContext context) { - Navigator.of(context).pop(); + static void hideLoadingDialog() { + if (Get.isOverlaysOpen) { + Get.until((route) => route.settings.name != _dialogId); + } } } diff --git a/lib/data/services/connection_service.dart b/lib/data/services/connection_service.dart index de5609f..d929023 100644 --- a/lib/data/services/connection_service.dart +++ b/lib/data/services/connection_service.dart @@ -42,7 +42,7 @@ class ConnectionService extends GetxService { isConnected.value = results.any((result) => result != ConnectivityResult.none); } - Future checkConnection() async { + Future isHaveConnection() async { final result = await _connectivity.checkConnectivity(); return !result.contains(ConnectivityResult.none); } diff --git a/lib/feature/join_room/binding/join_room_binding.dart b/lib/feature/join_room/binding/join_room_binding.dart index cde17c8..38f74aa 100644 --- a/lib/feature/join_room/binding/join_room_binding.dart +++ b/lib/feature/join_room/binding/join_room_binding.dart @@ -1,5 +1,6 @@ import 'package:get/get.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; import 'package:quiz_app/data/services/socket_service.dart'; import 'package:quiz_app/feature/join_room/controller/join_room_controller.dart'; @@ -8,6 +9,12 @@ class JoinRoomBinding extends Bindings { void dependencies() { Get.put(SocketService()); - Get.lazyPut(() => JoinRoomController(Get.find(), Get.find())); + Get.lazyPut( + () => JoinRoomController( + Get.find(), + Get.find(), + Get.find(), + ), + ); } } diff --git a/lib/feature/join_room/controller/join_room_controller.dart b/lib/feature/join_room/controller/join_room_controller.dart index 611dc7c..934c32e 100644 --- a/lib/feature/join_room/controller/join_room_controller.dart +++ b/lib/feature/join_room/controller/join_room_controller.dart @@ -1,23 +1,35 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/core/helper/connection_check.dart'; import 'package:quiz_app/core/utils/custom_floating_loading.dart'; +import 'package:quiz_app/core/utils/custom_notification.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/dto/waiting_room_dto.dart'; import 'package:quiz_app/data/models/quiz/quiz_info_model.dart'; import 'package:quiz_app/data/models/session/session_info_model.dart'; import 'package:quiz_app/data/models/session/session_response_model.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; import 'package:quiz_app/data/services/socket_service.dart'; class JoinRoomController extends GetxController { final SocketService _socketService; final UserController _userController; + final ConnectionService _connectionService; - JoinRoomController(this._socketService, this._userController); + JoinRoomController( + this._socketService, + this._userController, + this._connectionService, + ); final TextEditingController codeController = TextEditingController(); - void joinRoom() { + void joinRoom(BuildContext context) { + if (!_connectionService.isCurrentlyConnected) { + ConnectionNotification.noInternedConnection(); + return; + } final code = codeController.text.trim(); if (code.isEmpty) { @@ -29,18 +41,13 @@ class JoinRoomController extends GetxController { ); return; } - CustomFloatingLoading.showLoadingDialog(Get.context!); + CustomFloatingLoading.showLoadingDialog(); _socketService.initSocketConnection(); _socketService.joinRoom(sessionCode: code, userId: _userController.userData!.id); _socketService.errors.listen((error) { - Get.snackbar( - "not found", - "Ruangan tidak ditemukan", - backgroundColor: Get.theme.colorScheme.error.withValues(alpha: 0.9), - colorText: Colors.white, - ); - CustomFloatingLoading.hideLoadingDialog(Get.context!); + CustomNotification.error(title: "not found", message: "Ruangan tidak ditemukan"); + CustomFloatingLoading.hideLoadingDialog(); }); _socketService.roomMessages.listen((data) { @@ -49,7 +56,7 @@ class JoinRoomController extends GetxController { final Map sessionInfoJson = dataPayload["session_info"]; final Map quizInfoJson = dataPayload["quiz_info"]; - CustomFloatingLoading.hideLoadingDialog(Get.context!); + // CustomFloatingLoading.hideLoadingDialog(context); Get.toNamed( AppRoutes.waitRoomPage, arguments: WaitingRoomDTO( diff --git a/lib/feature/join_room/view/join_room_view.dart b/lib/feature/join_room/view/join_room_view.dart index ffcf3ea..7dd1777 100644 --- a/lib/feature/join_room/view/join_room_view.dart +++ b/lib/feature/join_room/view/join_room_view.dart @@ -182,7 +182,7 @@ class JoinRoomView extends GetView { const SizedBox(height: 30), GlobalButton( text: context.tr("join_quiz_now"), - onPressed: controller.joinRoom, + onPressed: () => controller.joinRoom(context), ), ], ), diff --git a/lib/feature/login/controllers/login_controller.dart b/lib/feature/login/controllers/login_controller.dart index 8dbcaad..ba8a8e0 100644 --- a/lib/feature/login/controllers/login_controller.dart +++ b/lib/feature/login/controllers/login_controller.dart @@ -88,7 +88,7 @@ class LoginController extends GetxController { } try { isLoading.value = true; - CustomFloatingLoading.showLoadingDialog(Get.context!); + CustomFloatingLoading.showLoadingDialog(); final LoginResponseModel response = await _authService.loginWithEmail( LoginRequestModel(email: email, password: password), @@ -99,11 +99,11 @@ class LoginController extends GetxController { await _userStorageService.saveUser(userEntity); _userController.setUserFromEntity(userEntity); _userStorageService.isLogged = true; - CustomFloatingLoading.hideLoadingDialog(Get.context!); + CustomFloatingLoading.hideLoadingDialog(); Get.offAllNamed(AppRoutes.mainPage); } catch (e, stackTrace) { logC.e(e, stackTrace: stackTrace); - CustomFloatingLoading.hideLoadingDialog(Get.context!); + CustomFloatingLoading.hideLoadingDialog(); CustomNotification.error(title: "Gagal", message: "Periksa kembali email dan kata sandi Anda"); } } @@ -114,12 +114,12 @@ class LoginController extends GetxController { return; } try { - CustomFloatingLoading.showLoadingDialog(Get.context!); + CustomFloatingLoading.showLoadingDialog(); final user = await _googleAuthService.signIn(); if (user == null) { Get.snackbar("Kesalahan", "Masuk dengan Google dibatalkan"); - CustomFloatingLoading.hideLoadingDialog(Get.context!); + CustomFloatingLoading.hideLoadingDialog(); return; } @@ -127,7 +127,7 @@ class LoginController extends GetxController { if (idToken == null || idToken.isEmpty) { Get.snackbar("Kesalahan", "Tidak menerima ID Token dari Google"); - CustomFloatingLoading.hideLoadingDialog(Get.context!); + CustomFloatingLoading.hideLoadingDialog(); return; } @@ -137,7 +137,7 @@ class LoginController extends GetxController { await _userStorageService.saveUser(userEntity); _userController.setUserFromEntity(userEntity); _userStorageService.isLogged = true; - CustomFloatingLoading.hideLoadingDialog(Get.context!); + CustomFloatingLoading.hideLoadingDialog(); Get.offAllNamed(AppRoutes.mainPage); } catch (e, stackTrace) { logC.e("Google Sign-In Error: $e", stackTrace: stackTrace); diff --git a/lib/feature/profile/controller/update_profile_controller.dart b/lib/feature/profile/controller/update_profile_controller.dart index 8f4ce8b..5d283ea 100644 --- a/lib/feature/profile/controller/update_profile_controller.dart +++ b/lib/feature/profile/controller/update_profile_controller.dart @@ -61,7 +61,7 @@ class UpdateProfileController extends GetxController { Future saveProfile() async { if (!_validateInputs()) return; - CustomFloatingLoading.showLoadingDialog(Get.context!); + CustomFloatingLoading.showLoadingDialog(); final isSuccessUpdate = await _userService.updateProfileData( _userController.userData!.id, @@ -98,7 +98,7 @@ class UpdateProfileController extends GetxController { Get.back(); CustomNotification.success(title: "Success", message: "Profile updated successfully"); - CustomFloatingLoading.hideLoadingDialog(Get.context!); + CustomFloatingLoading.hideLoadingDialog(); } bool _isValidDateFormat(String date) { diff --git a/lib/feature/quiz_creation/controller/quiz_creation_controller.dart b/lib/feature/quiz_creation/controller/quiz_creation_controller.dart index d4a3914..8886991 100644 --- a/lib/feature/quiz_creation/controller/quiz_creation_controller.dart +++ b/lib/feature/quiz_creation/controller/quiz_creation_controller.dart @@ -224,7 +224,7 @@ class QuizCreationController extends GetxController { } void generateQuiz() async { - CustomFloatingLoading.showLoadingDialog(Get.context!); + CustomFloatingLoading.showLoadingDialog(); try { BaseResponseModel> response = await _quizService.createQuizAuto(inputSentenceTC.text); @@ -262,7 +262,7 @@ class QuizCreationController extends GetxController { } catch (e) { logC.e("Error while generating quiz: $e"); } finally { - CustomFloatingLoading.hideLoadingDialog(Get.context!); + CustomFloatingLoading.hideLoadingDialog(); isGenerate.value = false; if (quizData.isNotEmpty && selectedQuizIndex.value == 0) { diff --git a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart index 177caf6..a74d267 100644 --- a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart +++ b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart @@ -92,7 +92,7 @@ class QuizPreviewController extends GetxController { } isLoading.value = true; - CustomFloatingLoading.showLoadingDialog(Get.context!); + CustomFloatingLoading.showLoadingDialog(); final now = DateTime.now(); final String formattedDate = "${now.day.toString().padLeft(2, '0')}-${now.month.toString().padLeft(2, '0')}-${now.year}"; @@ -122,7 +122,7 @@ class QuizPreviewController extends GetxController { logC.e(e); } finally { isLoading.value = false; - // CustomFloatingLoading.hideLoadingDialog(Get.context!); + // CustomFloatingLoading.hideLoadingDialog(); } } diff --git a/lib/feature/register/controller/register_controller.dart b/lib/feature/register/controller/register_controller.dart index 506e211..13816d8 100644 --- a/lib/feature/register/controller/register_controller.dart +++ b/lib/feature/register/controller/register_controller.dart @@ -81,7 +81,7 @@ class RegisterController extends GetxController { } try { - CustomFloatingLoading.showLoadingDialog(Get.context!); + CustomFloatingLoading.showLoadingDialog(); await _authService.register( RegisterRequestModel( email: email, @@ -93,10 +93,10 @@ class RegisterController extends GetxController { ); Get.back(); - CustomFloatingLoading.hideLoadingDialog(Get.context!); + CustomFloatingLoading.hideLoadingDialog(); CustomNotification.success(title: "Pendaftaran Berhasil", message: "Akun berhasil dibuat"); } catch (e) { - CustomFloatingLoading.hideLoadingDialog(Get.context!); + CustomFloatingLoading.hideLoadingDialog(); String errorMessage = e.toString().replaceFirst("Exception: ", ""); diff --git a/lib/feature/room_maker/binding/room_maker_binding.dart b/lib/feature/room_maker/binding/room_maker_binding.dart index 24d6211..73af401 100644 --- a/lib/feature/room_maker/binding/room_maker_binding.dart +++ b/lib/feature/room_maker/binding/room_maker_binding.dart @@ -1,5 +1,6 @@ import 'package:get/get.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; import 'package:quiz_app/data/services/quiz_service.dart'; import 'package:quiz_app/data/services/session_service.dart'; import 'package:quiz_app/data/services/socket_service.dart'; @@ -16,6 +17,7 @@ class RoomMakerBinding extends Bindings { Get.find(), Get.find(), Get.find(), + Get.find(), )); } } diff --git a/lib/feature/room_maker/controller/room_maker_controller.dart b/lib/feature/room_maker/controller/room_maker_controller.dart index e0b0fdc..c64c2ac 100644 --- a/lib/feature/room_maker/controller/room_maker_controller.dart +++ b/lib/feature/room_maker/controller/room_maker_controller.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/core/helper/connection_check.dart'; +import 'package:quiz_app/core/utils/custom_notification.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/dto/waiting_room_dto.dart'; import 'package:quiz_app/data/models/base/base_model.dart'; @@ -9,6 +11,7 @@ import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; import 'package:quiz_app/data/models/session/session_info_model.dart'; import 'package:quiz_app/data/models/session/session_request_model.dart'; import 'package:quiz_app/data/models/session/session_response_model.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; import 'package:quiz_app/data/services/quiz_service.dart'; import 'package:quiz_app/data/services/session_service.dart'; import 'package:quiz_app/data/services/socket_service.dart'; @@ -18,12 +21,14 @@ class RoomMakerController extends GetxController { final UserController _userController; final SocketService _socketService; final QuizService _quizService; + final ConnectionService _connectionService; RoomMakerController( this._sessionService, this._userController, this._socketService, this._quizService, + this._connectionService, ); final selectedQuiz = Rxn(); @@ -47,6 +52,10 @@ class RoomMakerController extends GetxController { } Future loadQuiz({bool reset = false}) async { + if (!await _connectionService.isHaveConnection()) { + ConnectionNotification.noInternedConnection(); + return; + } if (isLoading) return; isLoading = true; @@ -92,8 +101,13 @@ class RoomMakerController extends GetxController { } void onCreateRoom() async { - if (nameTC.text.trim().isEmpty || selectedQuiz.value == null) { - Get.snackbar("Gagal", "Nama room dan kuis harus dipilih."); + if (nameTC.text.trim().isEmpty || maxPlayerTC.text.trim().isEmpty || selectedQuiz.value == null) { + CustomNotification.error(title: "Gagal", message: "Nama room, maksimal pemain dan kuis harus dipilih."); + return; + } + + if (!await _connectionService.isHaveConnection()) { + ConnectionNotification.noInternedConnection(); return; } diff --git a/lib/feature/room_maker/view/room_maker_view.dart b/lib/feature/room_maker/view/room_maker_view.dart index 9c26e92..fe5eaea 100644 --- a/lib/feature/room_maker/view/room_maker_view.dart +++ b/lib/feature/room_maker/view/room_maker_view.dart @@ -588,52 +588,46 @@ class RoomMakerView extends GetView { } Widget _buildCreateRoomButton() { - return Obx(() { - final canCreate = controller.selectedQuiz.value != null && controller.nameTC.text.isNotEmpty && controller.maxPlayerTC.text.isNotEmpty; - - return AnimatedContainer( - duration: const Duration(milliseconds: 300), - width: MediaQuery.of(Get.context!).size.width - 32, - height: 56, - child: Material( - elevation: canCreate ? 8 : 2, + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + width: MediaQuery.of(Get.context!).size.width - 32, + height: 56, + child: Material( + elevation: 8, + borderRadius: BorderRadius.circular(16), + child: InkWell( borderRadius: BorderRadius.circular(16), - child: InkWell( - borderRadius: BorderRadius.circular(16), - onTap: canCreate ? controller.onCreateRoom : null, - child: Container( - decoration: BoxDecoration( - gradient: canCreate - ? LinearGradient( - colors: [AppColors.primaryBlue, AppColors.primaryBlue.withValues(alpha: 0.8)], - ) - : null, - color: !canCreate ? Colors.grey[300] : null, - borderRadius: BorderRadius.circular(16), + onTap: controller.onCreateRoom, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [AppColors.primaryBlue, AppColors.primaryBlue.withValues(alpha: 0.8)], ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.add_circle, - color: canCreate ? Colors.white : Colors.grey[500], - size: 24, + color: Colors.grey[300], + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.add_circle, + color: Colors.white, + size: 24, + ), + const SizedBox(width: 12), + Text( + "Buat Room Sekarang", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, ), - const SizedBox(width: 12), - Text( - "Buat Room Sekarang", - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: canCreate ? Colors.white : Colors.grey[500], - ), - ), - ], - ), + ), + ], ), ), ), - ); - }); + ), + ); } } From ae49bb34d098cbd156229cfeba723bb6f66489db Mon Sep 17 00:00:00 2001 From: akhdanre Date: Thu, 5 Jun 2025 20:35:35 +0700 Subject: [PATCH 100/104] fix: adding loading and connection limitation --- lib/core/endpoint/api_endpoint.dart | 4 +- lib/core/utils/custom_floating_loading.dart | 34 +++-- .../binding/detail_quiz_binding.dart | 8 +- .../controller/detail_quiz_controller.dart | 20 ++- .../detail_quiz/view/detail_quix_view.dart | 126 +++++++++--------- .../history/binding/history_binding.dart | 9 +- .../controller/history_controller.dart | 18 ++- lib/feature/home/binding/home_binding.dart | 4 + .../home/controller/home_controller.dart | 13 +- .../home/view/component/user_gretings.dart | 1 + .../controller/join_room_controller.dart | 6 +- .../library/binding/library_binding.dart | 7 +- .../controller/library_controller.dart | 21 ++- .../login/controllers/login_controller.dart | 24 ++-- .../bindings/navigation_binding.dart | 3 +- .../controllers/navigation_controller.dart | 18 +++ .../profile/binding/profile_binding.dart | 2 + .../binding/update_profile_binding.dart | 2 + .../controller/profile_controller.dart | 6 + .../controller/update_profile_controller.dart | 98 ++++++++------ .../binding/quiz_creation_binding.dart | 2 + .../controller/quiz_creation_controller.dart | 23 +++- .../binding/quiz_preview_binding.dart | 2 + .../controller/quiz_preview_controller.dart | 11 +- .../controller/register_controller.dart | 6 +- 25 files changed, 317 insertions(+), 151 deletions(-) diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart index 2cbebcd..9cfcf74 100644 --- a/lib/core/endpoint/api_endpoint.dart +++ b/lib/core/endpoint/api_endpoint.dart @@ -1,6 +1,6 @@ class APIEndpoint { - // static const String baseUrl = "http://192.168.107.43:5000"; - static const String baseUrl = "http://103.193.178.121:5000"; + static const String baseUrl = "http://192.168.1.13:5000"; + // static const String baseUrl = "http://103.193.178.121:5000"; static const String api = "$baseUrl/api"; static const String login = "/login"; diff --git a/lib/core/utils/custom_floating_loading.dart b/lib/core/utils/custom_floating_loading.dart index 6b1a581..6ea9a5d 100644 --- a/lib/core/utils/custom_floating_loading.dart +++ b/lib/core/utils/custom_floating_loading.dart @@ -1,24 +1,32 @@ import 'package:flutter/material.dart'; -import 'package:get/get.dart'; class CustomFloatingLoading { - static const String _dialogId = 'custom_loading'; + static OverlayEntry? _overlayEntry; - static void showLoadingDialog() { - Get.dialog( - PopScope( - canPop: false, - child: const Center(child: CircularProgressIndicator()), + static void showLoading(BuildContext context) { + if (_overlayEntry != null) return; + + _overlayEntry = OverlayEntry( + builder: (_) => Stack( + children: [ + ModalBarrier( + dismissible: false, + color: Colors.black.withValues(alpha: 0.5), + ), + const Center( + child: CircularProgressIndicator(), + ), + ], ), - barrierDismissible: false, - barrierColor: Colors.black.withAlpha(76), - name: _dialogId, ); + + Overlay.of(context).insert(_overlayEntry!); } - static void hideLoadingDialog() { - if (Get.isOverlaysOpen) { - Get.until((route) => route.settings.name != _dialogId); + static void hideLoading() { + if (_overlayEntry?.mounted == true) { + _overlayEntry?.remove(); } + _overlayEntry = null; } } diff --git a/lib/feature/detail_quiz/binding/detail_quiz_binding.dart b/lib/feature/detail_quiz/binding/detail_quiz_binding.dart index 500837a..30d0af5 100644 --- a/lib/feature/detail_quiz/binding/detail_quiz_binding.dart +++ b/lib/feature/detail_quiz/binding/detail_quiz_binding.dart @@ -1,4 +1,5 @@ import 'package:get/get.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; import 'package:quiz_app/data/services/quiz_service.dart'; import 'package:quiz_app/feature/detail_quiz/controller/detail_quiz_controller.dart'; @@ -8,6 +9,11 @@ class DetailQuizBinding extends Bindings { if (!Get.isRegistered()) { Get.lazyPut(() => QuizService()); } - Get.lazyPut(() => DetailQuizController(Get.find())); + Get.lazyPut( + () => DetailQuizController( + Get.find(), + Get.find(), + ), + ); } } diff --git a/lib/feature/detail_quiz/controller/detail_quiz_controller.dart b/lib/feature/detail_quiz/controller/detail_quiz_controller.dart index e089de2..ce2eba8 100644 --- a/lib/feature/detail_quiz/controller/detail_quiz_controller.dart +++ b/lib/feature/detail_quiz/controller/detail_quiz_controller.dart @@ -1,17 +1,20 @@ import 'package:get/get.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/core/helper/connection_check.dart'; import 'package:quiz_app/data/models/base/base_model.dart'; import 'package:quiz_app/data/models/quiz/library_quiz_model.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; import 'package:quiz_app/data/services/quiz_service.dart'; class DetailQuizController extends GetxController { final QuizService _quizService; + final ConnectionService _connectionService; - DetailQuizController(this._quizService); + DetailQuizController(this._quizService, this._connectionService); RxBool isLoading = true.obs; - late QuizData data; + QuizData? data; @override void onInit() { @@ -21,6 +24,11 @@ class DetailQuizController extends GetxController { void loadData() async { final quizId = Get.arguments as String; + if (!await _connectionService.isHaveConnection()) { + ConnectionNotification.noInternedConnection(); + isLoading.value = false; + return; + } getQuizData(quizId); } @@ -32,5 +40,11 @@ class DetailQuizController extends GetxController { isLoading.value = false; } - void goToPlayPage() => Get.toNamed(AppRoutes.playQuizPage, arguments: data); + void goToPlayPage() { + if (!_connectionService.isCurrentlyConnected) { + ConnectionNotification.noInternedConnection(); + return; + } + Get.toNamed(AppRoutes.playQuizPage, arguments: data); + } } diff --git a/lib/feature/detail_quiz/view/detail_quix_view.dart b/lib/feature/detail_quiz/view/detail_quix_view.dart index 3c467ea..07cfd98 100644 --- a/lib/feature/detail_quiz/view/detail_quix_view.dart +++ b/lib/feature/detail_quiz/view/detail_quix_view.dart @@ -30,70 +30,76 @@ class DetailQuizView extends GetView { body: SafeArea( child: Padding( padding: const EdgeInsets.all(20), - child: Obx( - () => controller.isLoading.value - ? const Center(child: LoadingWidget()) - : SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header Section - Text( - controller.data.title, - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - color: AppColors.darkText, - ), - ), - const SizedBox(height: 8), - Text( - controller.data.description ?? "", - style: const TextStyle( - fontSize: 14, - color: AppColors.softGrayText, - ), - ), - const SizedBox(height: 16), - Row( - children: [ - const Icon(Icons.calendar_today_rounded, size: 16, color: AppColors.softGrayText), - const SizedBox(width: 6), - Text( - controller.data.date ?? "", - style: const TextStyle(fontSize: 12, color: AppColors.softGrayText), - ), - const SizedBox(width: 12), - const Icon(Icons.timer_rounded, size: 16, color: AppColors.softGrayText), - const SizedBox(width: 6), - Text( - '${controller.data.limitDuration ~/ 60} ${tr('minutes_suffix')}', - style: const TextStyle(fontSize: 12, color: AppColors.softGrayText), - ), - ], - ), - const SizedBox(height: 20), + child: Obx(() { + if (controller.isLoading.value) { + return const Center(child: LoadingWidget()); + } - GlobalButton(text: tr('start_quiz'), onPressed: controller.goToPlayPage), - const SizedBox(height: 20), + if (controller.data == null) { + return const Center(child: Text("Tidak Ditemukan")); + } - const Divider(thickness: 1.2, color: AppColors.borderLight), - const SizedBox(height: 20), - - // Soal Section - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: controller.data.questionListings.length, - itemBuilder: (context, index) { - final question = controller.data.questionListings[index]; - return _buildQuestionItem(question, index + 1); - }, - ), - ], + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Section + Text( + controller.data!.title, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: AppColors.darkText, ), ), - ), + const SizedBox(height: 8), + Text( + controller.data!.description ?? "", + style: const TextStyle( + fontSize: 14, + color: AppColors.softGrayText, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + const Icon(Icons.calendar_today_rounded, size: 16, color: AppColors.softGrayText), + const SizedBox(width: 6), + Text( + controller.data!.date ?? "", + style: const TextStyle(fontSize: 12, color: AppColors.softGrayText), + ), + const SizedBox(width: 12), + const Icon(Icons.timer_rounded, size: 16, color: AppColors.softGrayText), + const SizedBox(width: 6), + Text( + '${controller.data!.limitDuration ~/ 60} ${tr('minutes_suffix')}', + style: const TextStyle(fontSize: 12, color: AppColors.softGrayText), + ), + ], + ), + const SizedBox(height: 20), + + GlobalButton(text: tr('start_quiz'), onPressed: controller.goToPlayPage), + const SizedBox(height: 20), + + const Divider(thickness: 1.2, color: AppColors.borderLight), + const SizedBox(height: 20), + + // Soal Section + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: controller.data!.questionListings.length, + itemBuilder: (context, index) { + final question = controller.data!.questionListings[index]; + return _buildQuestionItem(question, index + 1); + }, + ), + ], + ), + ); + }), ), ), ); diff --git a/lib/feature/history/binding/history_binding.dart b/lib/feature/history/binding/history_binding.dart index 725ec3c..d6f6b81 100644 --- a/lib/feature/history/binding/history_binding.dart +++ b/lib/feature/history/binding/history_binding.dart @@ -1,5 +1,6 @@ import 'package:get/get.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; import 'package:quiz_app/data/services/history_service.dart'; import 'package:quiz_app/feature/history/controller/history_controller.dart'; @@ -7,6 +8,12 @@ class HistoryBinding extends Bindings { @override void dependencies() { Get.lazyPut(() => HistoryService()); - Get.lazyPut(() => HistoryController(Get.find(), Get.find())); + Get.lazyPut( + () => HistoryController( + Get.find(), + Get.find(), + Get.find(), + ), + ); } } diff --git a/lib/feature/history/controller/history_controller.dart b/lib/feature/history/controller/history_controller.dart index 9801849..edc7856 100644 --- a/lib/feature/history/controller/history_controller.dart +++ b/lib/feature/history/controller/history_controller.dart @@ -1,14 +1,21 @@ import 'package:get/get.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/core/helper/connection_check.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/models/history/quiz_history.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; import 'package:quiz_app/data/services/history_service.dart'; class HistoryController extends GetxController { final HistoryService _historyService; final UserController _userController; + final ConnectionService _connectionService; - HistoryController(this._historyService, this._userController); + HistoryController( + this._historyService, + this._userController, + this._connectionService, + ); RxBool isLoading = true.obs; @@ -17,10 +24,15 @@ class HistoryController extends GetxController { @override void onInit() { super.onInit(); - loadDummyHistory(); + loadHistory(); } - void loadDummyHistory() async { + void loadHistory() async { + if (!await _connectionService.isHaveConnection()) { + ConnectionNotification.noInternedConnection(); + return; + } + historyList.value = await _historyService.getHistory(_userController.userData!.id) ?? []; isLoading.value = false; } diff --git a/lib/feature/home/binding/home_binding.dart b/lib/feature/home/binding/home_binding.dart index e1b47cc..040e687 100644 --- a/lib/feature/home/binding/home_binding.dart +++ b/lib/feature/home/binding/home_binding.dart @@ -1,4 +1,6 @@ import 'package:get/get.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; import 'package:quiz_app/data/services/quiz_service.dart'; import 'package:quiz_app/data/services/subject_service.dart'; import 'package:quiz_app/feature/home/controller/home_controller.dart'; @@ -10,8 +12,10 @@ class HomeBinding extends Bindings { Get.lazyPut(() => SubjectService()); Get.lazyPut( () => HomeController( + Get.find(), Get.find(), Get.find(), + Get.find(), ), ); } diff --git a/lib/feature/home/controller/home_controller.dart b/lib/feature/home/controller/home_controller.dart index 17a3359..6f7429d 100644 --- a/lib/feature/home/controller/home_controller.dart +++ b/lib/feature/home/controller/home_controller.dart @@ -1,24 +1,28 @@ import 'package:get/get.dart'; import 'package:quiz_app/app/const/enums/listing_type.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/core/helper/connection_check.dart'; import 'package:quiz_app/core/utils/logger.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/models/base/base_model.dart'; import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; import 'package:quiz_app/data/models/subject/subject_model.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; import 'package:quiz_app/data/services/quiz_service.dart'; import 'package:quiz_app/data/services/subject_service.dart'; import 'package:quiz_app/feature/navigation/controllers/navigation_controller.dart'; class HomeController extends GetxController { - final UserController _userController = Get.find(); - + final UserController _userController; final QuizService _quizService; final SubjectService _subjectService; + final ConnectionService _connectionService; HomeController( + this._userController, this._quizService, this._subjectService, + this._connectionService, ); RxInt timeStatus = 1.obs; @@ -39,6 +43,10 @@ class HomeController extends GetxController { } void _getRecomendationQuiz() async { + if (!await _connectionService.isHaveConnection()) { + ConnectionNotification.noInternedConnection(); + return; + } BaseResponseModel? response = await _quizService.recommendationQuiz(userId: _userController.userData!.id); if (response != null) { data.assignAll(response.data as List); @@ -46,6 +54,7 @@ class HomeController extends GetxController { } void loadSubjectData() async { + if (!_connectionService.isCurrentlyConnected) return; try { final response = await _subjectService.getSubject(); subjects.assignAll(response.data!); diff --git a/lib/feature/home/view/component/user_gretings.dart b/lib/feature/home/view/component/user_gretings.dart index 147e58d..a563230 100644 --- a/lib/feature/home/view/component/user_gretings.dart +++ b/lib/feature/home/view/component/user_gretings.dart @@ -5,6 +5,7 @@ class UserGretingsComponent extends StatelessWidget { final String userName; final String? userImage; final int greatingStatus; + const UserGretingsComponent({ super.key, required this.userName, diff --git a/lib/feature/join_room/controller/join_room_controller.dart b/lib/feature/join_room/controller/join_room_controller.dart index 934c32e..e099164 100644 --- a/lib/feature/join_room/controller/join_room_controller.dart +++ b/lib/feature/join_room/controller/join_room_controller.dart @@ -41,13 +41,13 @@ class JoinRoomController extends GetxController { ); return; } - CustomFloatingLoading.showLoadingDialog(); + CustomFloatingLoading.showLoading(Get.overlayContext!); _socketService.initSocketConnection(); _socketService.joinRoom(sessionCode: code, userId: _userController.userData!.id); _socketService.errors.listen((error) { CustomNotification.error(title: "not found", message: "Ruangan tidak ditemukan"); - CustomFloatingLoading.hideLoadingDialog(); + CustomFloatingLoading.hideLoading(); }); _socketService.roomMessages.listen((data) { @@ -56,7 +56,7 @@ class JoinRoomController extends GetxController { final Map sessionInfoJson = dataPayload["session_info"]; final Map quizInfoJson = dataPayload["quiz_info"]; - // CustomFloatingLoading.hideLoadingDialog(context); + // CustomFloatingLoading.showLoading(context); Get.toNamed( AppRoutes.waitRoomPage, arguments: WaitingRoomDTO( diff --git a/lib/feature/library/binding/library_binding.dart b/lib/feature/library/binding/library_binding.dart index 6bdaf97..8446aa5 100644 --- a/lib/feature/library/binding/library_binding.dart +++ b/lib/feature/library/binding/library_binding.dart @@ -1,5 +1,6 @@ import 'package:get/get.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; import 'package:quiz_app/data/services/quiz_service.dart'; import 'package:quiz_app/feature/library/controller/library_controller.dart'; @@ -9,6 +10,10 @@ class LibraryBinding extends Bindings { if (!Get.isRegistered()) { Get.lazyPut(() => QuizService()); } - Get.lazyPut(() => LibraryController(Get.find(), Get.find())); + Get.lazyPut(() => LibraryController( + Get.find(), + Get.find(), + Get.find(), + )); } } diff --git a/lib/feature/library/controller/library_controller.dart b/lib/feature/library/controller/library_controller.dart index 37a5425..35c6962 100644 --- a/lib/feature/library/controller/library_controller.dart +++ b/lib/feature/library/controller/library_controller.dart @@ -1,19 +1,26 @@ import 'package:get/get.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/core/helper/connection_check.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/models/base/base_model.dart'; import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; import 'package:quiz_app/data/services/quiz_service.dart'; class LibraryController extends GetxController { + final QuizService _quizService; + final UserController _userController; + final ConnectionService _connectionService; + + LibraryController( + this._quizService, + this._userController, + this._connectionService, + ); + RxList quizs = [].obs; RxBool isLoading = true.obs; RxString emptyMessage = "".obs; - - final QuizService _quizService; - final UserController _userController; - LibraryController(this._quizService, this._userController); - int currentPage = 1; @override @@ -23,6 +30,10 @@ class LibraryController extends GetxController { } void loadUserQuiz() async { + if (!await _connectionService.isHaveConnection()) { + ConnectionNotification.noInternedConnection(); + return; + } try { isLoading.value = true; BaseResponseModel>? response = await _quizService.userQuiz(_userController.userData!.id, currentPage); diff --git a/lib/feature/login/controllers/login_controller.dart b/lib/feature/login/controllers/login_controller.dart index ba8a8e0..71f66e2 100644 --- a/lib/feature/login/controllers/login_controller.dart +++ b/lib/feature/login/controllers/login_controller.dart @@ -37,13 +37,15 @@ class LoginController extends GetxController { final RxBool isPasswordHidden = true.obs; final RxBool isLoading = false.obs; + late Worker _connectionWorker; + @override void onInit() { super.onInit(); emailController.addListener(validateFields); passwordController.addListener(validateFields); - ever(_connectionService.isConnected, (value) { + _connectionWorker = ever(_connectionService.isConnected, (value) { if (!value) { ConnectionNotification.noInternedConnection(); } else { @@ -88,7 +90,7 @@ class LoginController extends GetxController { } try { isLoading.value = true; - CustomFloatingLoading.showLoadingDialog(); + CustomFloatingLoading.showLoading(Get.overlayContext!); final LoginResponseModel response = await _authService.loginWithEmail( LoginRequestModel(email: email, password: password), @@ -99,11 +101,11 @@ class LoginController extends GetxController { await _userStorageService.saveUser(userEntity); _userController.setUserFromEntity(userEntity); _userStorageService.isLogged = true; - CustomFloatingLoading.hideLoadingDialog(); + CustomFloatingLoading.hideLoading(); Get.offAllNamed(AppRoutes.mainPage); } catch (e, stackTrace) { logC.e(e, stackTrace: stackTrace); - CustomFloatingLoading.hideLoadingDialog(); + CustomFloatingLoading.hideLoading(); CustomNotification.error(title: "Gagal", message: "Periksa kembali email dan kata sandi Anda"); } } @@ -114,12 +116,12 @@ class LoginController extends GetxController { return; } try { - CustomFloatingLoading.showLoadingDialog(); + CustomFloatingLoading.showLoading(Get.overlayContext!); final user = await _googleAuthService.signIn(); if (user == null) { Get.snackbar("Kesalahan", "Masuk dengan Google dibatalkan"); - CustomFloatingLoading.hideLoadingDialog(); + CustomFloatingLoading.hideLoading(); return; } @@ -127,7 +129,7 @@ class LoginController extends GetxController { if (idToken == null || idToken.isEmpty) { Get.snackbar("Kesalahan", "Tidak menerima ID Token dari Google"); - CustomFloatingLoading.hideLoadingDialog(); + CustomFloatingLoading.hideLoading(); return; } @@ -137,7 +139,7 @@ class LoginController extends GetxController { await _userStorageService.saveUser(userEntity); _userController.setUserFromEntity(userEntity); _userStorageService.isLogged = true; - CustomFloatingLoading.hideLoadingDialog(); + CustomFloatingLoading.hideLoading(); Get.offAllNamed(AppRoutes.mainPage); } catch (e, stackTrace) { logC.e("Google Sign-In Error: $e", stackTrace: stackTrace); @@ -160,4 +162,10 @@ class LoginController extends GetxController { phone: response.phone, ); } + + @override + void onClose() { + _connectionWorker.dispose(); + super.onClose(); + } } diff --git a/lib/feature/navigation/bindings/navigation_binding.dart b/lib/feature/navigation/bindings/navigation_binding.dart index a3d6e66..5d39678 100644 --- a/lib/feature/navigation/bindings/navigation_binding.dart +++ b/lib/feature/navigation/bindings/navigation_binding.dart @@ -1,10 +1,11 @@ // feature/navbar/binding/navbar_binding.dart import 'package:get/get.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; import 'package:quiz_app/feature/navigation/controllers/navigation_controller.dart'; class NavbarBinding extends Bindings { @override void dependencies() { - Get.lazyPut(() => NavigationController()); + Get.lazyPut(() => NavigationController(Get.find())); } } diff --git a/lib/feature/navigation/controllers/navigation_controller.dart b/lib/feature/navigation/controllers/navigation_controller.dart index a281e5f..ffef10a 100644 --- a/lib/feature/navigation/controllers/navigation_controller.dart +++ b/lib/feature/navigation/controllers/navigation_controller.dart @@ -1,8 +1,14 @@ import 'package:get/get.dart'; +import 'package:quiz_app/core/helper/connection_check.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; class NavigationController extends GetxController { RxInt selectedIndex = 0.obs; + final ConnectionService _connectionService; + + NavigationController(this._connectionService); + @override void onInit() { super.onInit(); @@ -12,6 +18,18 @@ class NavigationController extends GetxController { } } + @override + void onReady() { + ever(_connectionService.isConnected, (value) { + if (!value) { + ConnectionNotification.noInternedConnection(); + } else { + ConnectionNotification.internetConnected(); + } + }); + super.onReady(); + } + void changePage(int page) { selectedIndex.value = page; } diff --git a/lib/feature/profile/binding/profile_binding.dart b/lib/feature/profile/binding/profile_binding.dart index 086bfdd..9da327e 100644 --- a/lib/feature/profile/binding/profile_binding.dart +++ b/lib/feature/profile/binding/profile_binding.dart @@ -1,5 +1,6 @@ import 'package:get/get.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; import 'package:quiz_app/data/services/google_auth_service.dart'; import 'package:quiz_app/data/services/user_service.dart'; import 'package:quiz_app/data/services/user_storage_service.dart'; @@ -15,6 +16,7 @@ class ProfileBinding extends Bindings { Get.find(), Get.find(), Get.find(), + Get.find(), )); } } diff --git a/lib/feature/profile/binding/update_profile_binding.dart b/lib/feature/profile/binding/update_profile_binding.dart index db18f20..a09dbe9 100644 --- a/lib/feature/profile/binding/update_profile_binding.dart +++ b/lib/feature/profile/binding/update_profile_binding.dart @@ -1,5 +1,6 @@ import 'package:get/get.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; import 'package:quiz_app/data/services/user_service.dart'; import 'package:quiz_app/data/services/user_storage_service.dart'; import 'package:quiz_app/feature/profile/controller/update_profile_controller.dart'; @@ -12,6 +13,7 @@ class UpdateProfileBinding extends Bindings { Get.find(), Get.find(), Get.find(), + Get.find(), )); } } diff --git a/lib/feature/profile/controller/profile_controller.dart b/lib/feature/profile/controller/profile_controller.dart index d291820..44da7fe 100644 --- a/lib/feature/profile/controller/profile_controller.dart +++ b/lib/feature/profile/controller/profile_controller.dart @@ -8,6 +8,7 @@ import 'package:quiz_app/core/endpoint/api_endpoint.dart'; import 'package:quiz_app/core/utils/logger.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/models/user/user_stat_model.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; import 'package:quiz_app/data/services/google_auth_service.dart'; import 'package:quiz_app/data/services/user_service.dart'; import 'package:quiz_app/data/services/user_storage_service.dart'; @@ -18,12 +19,14 @@ class ProfileController extends GetxController { final UserStorageService _userStorageService; final GoogleAuthService _googleAuthService; final UserService _userService; + final ConnectionService _connectionService; ProfileController( this._userController, this._userStorageService, this._googleAuthService, this._userService, + this._connectionService, ); // User basic info @@ -74,6 +77,9 @@ class ProfileController extends GetxController { } void loadUserStat() async { + if (!await _connectionService.isHaveConnection()) { + return; + } try { final result = await _userService.getUserStat(_userController.userData!.id); if (result != null) { diff --git a/lib/feature/profile/controller/update_profile_controller.dart b/lib/feature/profile/controller/update_profile_controller.dart index 5d283ea..bfbf60f 100644 --- a/lib/feature/profile/controller/update_profile_controller.dart +++ b/lib/feature/profile/controller/update_profile_controller.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/core/helper/connection_check.dart'; import 'package:quiz_app/core/utils/custom_floating_loading.dart'; import 'package:quiz_app/core/utils/custom_notification.dart'; +import 'package:quiz_app/core/utils/logger.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; import 'package:quiz_app/data/entity/user/user_entity.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; import 'package:quiz_app/data/services/user_service.dart'; import 'package:quiz_app/data/services/user_storage_service.dart'; @@ -11,11 +14,13 @@ class UpdateProfileController extends GetxController { final UserController _userController; final UserStorageService _userStorageService; final UserService _userService; + final ConnectionService _connectionService; UpdateProfileController( this._userService, this._userController, this._userStorageService, + this._connectionService, ); final nameController = TextEditingController(); @@ -44,61 +49,76 @@ class UpdateProfileController extends GetxController { final name = nameController.text.trim(); final phone = phoneController.text.trim(); final birthDate = birthDateController.text.trim(); - print(birthDate); if (name.isEmpty || phone.isEmpty || birthDate.isEmpty) { - Get.snackbar('Validation Error', 'All fields must be filled.', snackPosition: SnackPosition.TOP); + CustomNotification.error( + title: 'Validation Error', + message: 'All fields must be filled.', + ); return false; } if (!_isValidDateFormat(birthDate)) { - Get.snackbar('Validation Error', 'birth date must valid.', snackPosition: SnackPosition.TOP); + CustomNotification.error( + title: 'Validation Error', + message: 'birth date must valid.', + ); return false; } return true; } Future saveProfile() async { - if (!_validateInputs()) return; + if (!await _connectionService.isHaveConnection()) { + // Get.back(); - CustomFloatingLoading.showLoadingDialog(); - - final isSuccessUpdate = await _userService.updateProfileData( - _userController.userData!.id, - nameController.text.trim(), - birthDate: birthDateController.text.trim(), - phone: phoneController.text.trim(), - locale: selectedLocale.value, - ); - - if (isSuccessUpdate) { - final response = await _userService.getUserData(_userController.userData!.id); - - if (response?.data != null) { - final userNew = response!.data!; - final newUser = UserEntity( - id: userNew.id, - email: userNew.email, - name: userNew.name, - birthDate: userNew.birthDate, - locale: userNew.locale, - picUrl: userNew.picUrl, - phone: userNew.phone, - ); - - _userStorageService.saveUser(newUser); - _userController.userData = newUser; - - _userController.email.value = userNew.email; - _userController.userName.value = userNew.name; - _userController.userImage.value = userNew.picUrl; - } + ConnectionNotification.noInternedConnection(); + return; } - Get.back(); + if (!_validateInputs()) return; - CustomNotification.success(title: "Success", message: "Profile updated successfully"); - CustomFloatingLoading.hideLoadingDialog(); + try { + CustomFloatingLoading.showLoading(Get.overlayContext!); + final isSuccessUpdate = await _userService.updateProfileData( + _userController.userData!.id, + nameController.text.trim(), + birthDate: birthDateController.text.trim(), + phone: phoneController.text.trim(), + locale: selectedLocale.value, + ); + + if (isSuccessUpdate) { + final response = await _userService.getUserData(_userController.userData!.id); + + if (response?.data != null) { + final userNew = response!.data!; + final newUser = UserEntity( + id: userNew.id, + email: userNew.email, + name: userNew.name, + birthDate: userNew.birthDate, + locale: userNew.locale, + picUrl: userNew.picUrl, + phone: userNew.phone, + ); + + _userStorageService.saveUser(newUser); + _userController.userData = newUser; + + _userController.email.value = userNew.email; + _userController.userName.value = userNew.name; + _userController.userImage.value = userNew.picUrl; + } + } + CustomFloatingLoading.hideLoading(); + + Get.back(); + CustomNotification.success(title: "Success", message: "Profile updated successfully"); + } catch (e) { + CustomNotification.success(title: "something wrong", message: "failed to update profile"); + logC.e(e); + } } bool _isValidDateFormat(String date) { diff --git a/lib/feature/quiz_creation/binding/quiz_creation_binding.dart b/lib/feature/quiz_creation/binding/quiz_creation_binding.dart index 8fd8031..64de799 100644 --- a/lib/feature/quiz_creation/binding/quiz_creation_binding.dart +++ b/lib/feature/quiz_creation/binding/quiz_creation_binding.dart @@ -1,4 +1,5 @@ import "package:get/get.dart"; +import "package:quiz_app/data/services/connection_service.dart"; import "package:quiz_app/data/services/quiz_service.dart"; import "package:quiz_app/feature/quiz_creation/controller/quiz_creation_controller.dart"; @@ -9,6 +10,7 @@ class QuizCreationBinding extends Bindings { Get.lazyPut( () => QuizCreationController( Get.find(), + Get.find(), ), ); } diff --git a/lib/feature/quiz_creation/controller/quiz_creation_controller.dart b/lib/feature/quiz_creation/controller/quiz_creation_controller.dart index 8886991..cd47fc5 100644 --- a/lib/feature/quiz_creation/controller/quiz_creation_controller.dart +++ b/lib/feature/quiz_creation/controller/quiz_creation_controller.dart @@ -5,16 +5,22 @@ import 'package:quiz_app/app/const/enums/question_type.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/component/notification/delete_confirmation.dart'; import 'package:quiz_app/component/notification/pop_up_confirmation.dart'; +import 'package:quiz_app/core/helper/connection_check.dart'; import 'package:quiz_app/core/utils/custom_floating_loading.dart'; +import 'package:quiz_app/core/utils/custom_notification.dart'; import 'package:quiz_app/core/utils/logger.dart'; import 'package:quiz_app/data/models/base/base_model.dart'; import 'package:quiz_app/data/models/quiz/quiestion_data_model.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; import 'package:quiz_app/data/services/quiz_service.dart'; class QuizCreationController extends GetxController { final QuizService _quizService; - - QuizCreationController(this._quizService); + final ConnectionService _connectionService; + QuizCreationController( + this._quizService, + this._connectionService, + ); final TextEditingController inputSentenceTC = TextEditingController(); final TextEditingController questionTC = TextEditingController(); @@ -224,7 +230,16 @@ class QuizCreationController extends GetxController { } void generateQuiz() async { - CustomFloatingLoading.showLoadingDialog(); + if (!await _connectionService.isHaveConnection()) { + ConnectionNotification.noInternedConnection(); + return; + } + + if (inputSentenceTC.text.trim().isEmpty) { + CustomNotification.error(title: "Gagal", message: "kalimat atau paragraph tidak boleh kosong"); + return; + } + CustomFloatingLoading.showLoading(Get.overlayContext!); try { BaseResponseModel> response = await _quizService.createQuizAuto(inputSentenceTC.text); @@ -262,7 +277,7 @@ class QuizCreationController extends GetxController { } catch (e) { logC.e("Error while generating quiz: $e"); } finally { - CustomFloatingLoading.hideLoadingDialog(); + CustomFloatingLoading.hideLoading(); isGenerate.value = false; if (quizData.isNotEmpty && selectedQuizIndex.value == 0) { diff --git a/lib/feature/quiz_preview/binding/quiz_preview_binding.dart b/lib/feature/quiz_preview/binding/quiz_preview_binding.dart index efbc228..a5a6d9b 100644 --- a/lib/feature/quiz_preview/binding/quiz_preview_binding.dart +++ b/lib/feature/quiz_preview/binding/quiz_preview_binding.dart @@ -1,5 +1,6 @@ import 'package:get/get.dart'; import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; import 'package:quiz_app/data/services/quiz_service.dart'; import 'package:quiz_app/data/services/subject_service.dart'; import 'package:quiz_app/feature/quiz_preview/controller/quiz_preview_controller.dart'; @@ -13,6 +14,7 @@ class QuizPreviewBinding extends Bindings { Get.find(), Get.find(), Get.find(), + Get.find(), )); } } diff --git a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart index a74d267..c13dbfb 100644 --- a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart +++ b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:quiz_app/app/const/enums/question_type.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/core/helper/connection_check.dart'; import 'package:quiz_app/core/utils/custom_floating_loading.dart'; import 'package:quiz_app/core/utils/custom_notification.dart'; import 'package:quiz_app/core/utils/logger.dart'; @@ -10,6 +11,7 @@ import 'package:quiz_app/data/models/quiz/question_create_request.dart'; import 'package:quiz_app/data/models/quiz/question_listings_model.dart'; import 'package:quiz_app/data/models/quiz/quiestion_data_model.dart'; import 'package:quiz_app/data/models/subject/subject_model.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; import 'package:quiz_app/data/services/quiz_service.dart'; import 'package:quiz_app/data/services/subject_service.dart'; @@ -20,11 +22,13 @@ class QuizPreviewController extends GetxController { final QuizService _quizService; final UserController _userController; final SubjectService _subjectService; + final ConnectionService _connectionService; QuizPreviewController( this._quizService, this._userController, this._subjectService, + this._connectionService, ); RxBool isPublic = false.obs; @@ -70,6 +74,10 @@ class QuizPreviewController extends GetxController { Future onSaveQuiz() async { try { + if (!await _connectionService.isHaveConnection()) { + ConnectionNotification.noInternedConnection(); + return; + } if (isLoading.value) return; final title = titleController.text.trim(); @@ -92,7 +100,7 @@ class QuizPreviewController extends GetxController { } isLoading.value = true; - CustomFloatingLoading.showLoadingDialog(); + CustomFloatingLoading.showLoading(Get.overlayContext!); final now = DateTime.now(); final String formattedDate = "${now.day.toString().padLeft(2, '0')}-${now.month.toString().padLeft(2, '0')}-${now.year}"; @@ -122,7 +130,6 @@ class QuizPreviewController extends GetxController { logC.e(e); } finally { isLoading.value = false; - // CustomFloatingLoading.hideLoadingDialog(); } } diff --git a/lib/feature/register/controller/register_controller.dart b/lib/feature/register/controller/register_controller.dart index 13816d8..09db64a 100644 --- a/lib/feature/register/controller/register_controller.dart +++ b/lib/feature/register/controller/register_controller.dart @@ -81,7 +81,7 @@ class RegisterController extends GetxController { } try { - CustomFloatingLoading.showLoadingDialog(); + CustomFloatingLoading.showLoading(Get.overlayContext!); await _authService.register( RegisterRequestModel( email: email, @@ -93,10 +93,10 @@ class RegisterController extends GetxController { ); Get.back(); - CustomFloatingLoading.hideLoadingDialog(); + CustomFloatingLoading.hideLoading(); CustomNotification.success(title: "Pendaftaran Berhasil", message: "Akun berhasil dibuat"); } catch (e) { - CustomFloatingLoading.hideLoadingDialog(); + CustomFloatingLoading.hideLoading(); String errorMessage = e.toString().replaceFirst("Exception: ", ""); From 3cce4faadd16c5394ceced7d0ae212899783f8c7 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Thu, 5 Jun 2025 20:44:43 +0700 Subject: [PATCH 101/104] feat: update data profile --- lib/feature/profile/controller/profile_controller.dart | 9 +++++++-- .../profile/controller/update_profile_controller.dart | 5 ++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/feature/profile/controller/profile_controller.dart b/lib/feature/profile/controller/profile_controller.dart index 44da7fe..cf08049 100644 --- a/lib/feature/profile/controller/profile_controller.dart +++ b/lib/feature/profile/controller/profile_controller.dart @@ -71,6 +71,7 @@ class ProfileController extends GetxController { birthDate.value = _userController.userData?.birthDate ?? ""; phoneNumber.value = _userController.userData?.phone ?? ""; joinDate.value = _userController.userData?.createdAt ?? ""; + } catch (e, stackTrace) { logC.e("Failed to load user profile data: $e", stackTrace: stackTrace); } @@ -112,8 +113,12 @@ class ProfileController extends GetxController { } } - void editProfile() { - Get.toNamed(AppRoutes.updateProfilePage); + void editProfile() async { + final bool resultUpdate = await Get.toNamed(AppRoutes.updateProfilePage); + + if (resultUpdate) { + loadUserProfileData(); + } } void changeLanguage(BuildContext context, String languageCode, String countryCode) async { diff --git a/lib/feature/profile/controller/update_profile_controller.dart b/lib/feature/profile/controller/update_profile_controller.dart index bfbf60f..57d4e9b 100644 --- a/lib/feature/profile/controller/update_profile_controller.dart +++ b/lib/feature/profile/controller/update_profile_controller.dart @@ -70,8 +70,6 @@ class UpdateProfileController extends GetxController { Future saveProfile() async { if (!await _connectionService.isHaveConnection()) { - // Get.back(); - ConnectionNotification.noInternedConnection(); return; } @@ -101,6 +99,7 @@ class UpdateProfileController extends GetxController { locale: userNew.locale, picUrl: userNew.picUrl, phone: userNew.phone, + createdAt: userNew.createdAt, ); _userStorageService.saveUser(newUser); @@ -113,7 +112,7 @@ class UpdateProfileController extends GetxController { } CustomFloatingLoading.hideLoading(); - Get.back(); + Get.back(result: true); CustomNotification.success(title: "Success", message: "Profile updated successfully"); } catch (e) { CustomNotification.success(title: "something wrong", message: "failed to update profile"); From 17cee7b7d76349dcf3965b384adc730c59c292d5 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Thu, 5 Jun 2025 20:48:28 +0700 Subject: [PATCH 102/104] feat: adjust minor user entity --- lib/data/entity/user/user_entity.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/data/entity/user/user_entity.dart b/lib/data/entity/user/user_entity.dart index d147830..fd3b375 100644 --- a/lib/data/entity/user/user_entity.dart +++ b/lib/data/entity/user/user_entity.dart @@ -41,7 +41,7 @@ class UserEntity { 'birth_date': birthDate, 'locale': locale, 'phone': phone, - "create_at": createdAt, + "created_at": createdAt, }; } } From 576f5b27ea82ecaecf8e54f4e2b577a7ba7cac17 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sat, 7 Jun 2025 14:35:48 +0700 Subject: [PATCH 103/104] feat: penyesuaian pada beberapa logic component --- assets/translations/en-US.json | 4 ++-- lib/core/endpoint/api_endpoint.dart | 4 ++-- .../join_room/controller/join_room_controller.dart | 2 +- .../controller/quiz_creation_controller.dart | 2 ++ .../quiz_preview/controller/quiz_preview_controller.dart | 3 +++ .../room_maker/controller/room_maker_controller.dart | 8 ++++++++ 6 files changed, 18 insertions(+), 5 deletions(-) diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index ef0aed4..1d589f6 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -38,8 +38,8 @@ "library_title": "Quiz Library", "library_description": "A collection of quiz questions created for study.", "no_quiz_available": "No quizzes available yet.", - "quiz_count_label": "Quizzes", - "quiz_count_named": "{total} Quizzes", + "quiz_count_label": "Quiz", + "quiz_count_named": "{total} Quiz", "history_title": "Quiz History", "history_subtitle": "Review the quizzes you've taken", diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart index 9cfcf74..bd4e5ee 100644 --- a/lib/core/endpoint/api_endpoint.dart +++ b/lib/core/endpoint/api_endpoint.dart @@ -1,6 +1,6 @@ class APIEndpoint { - static const String baseUrl = "http://192.168.1.13:5000"; - // static const String baseUrl = "http://103.193.178.121:5000"; + // static const String baseUrl = "http://192.168.1.13:5000"; + static const String baseUrl = "http://103.193.178.121:5000"; static const String api = "$baseUrl/api"; static const String login = "/login"; diff --git a/lib/feature/join_room/controller/join_room_controller.dart b/lib/feature/join_room/controller/join_room_controller.dart index e099164..c03e583 100644 --- a/lib/feature/join_room/controller/join_room_controller.dart +++ b/lib/feature/join_room/controller/join_room_controller.dart @@ -56,7 +56,7 @@ class JoinRoomController extends GetxController { final Map sessionInfoJson = dataPayload["session_info"]; final Map quizInfoJson = dataPayload["quiz_info"]; - // CustomFloatingLoading.showLoading(context); + CustomFloatingLoading.hideLoading(); Get.toNamed( AppRoutes.waitRoomPage, arguments: WaitingRoomDTO( diff --git a/lib/feature/quiz_creation/controller/quiz_creation_controller.dart b/lib/feature/quiz_creation/controller/quiz_creation_controller.dart index cd47fc5..ad9e2ec 100644 --- a/lib/feature/quiz_creation/controller/quiz_creation_controller.dart +++ b/lib/feature/quiz_creation/controller/quiz_creation_controller.dart @@ -280,6 +280,8 @@ class QuizCreationController extends GetxController { CustomFloatingLoading.hideLoading(); isGenerate.value = false; + inputSentenceTC.text = ""; + if (quizData.isNotEmpty && selectedQuizIndex.value == 0) { final data = quizData[0]; questionTC.text = data.question ?? ""; diff --git a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart index c13dbfb..08f4711 100644 --- a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart +++ b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart @@ -96,6 +96,7 @@ class QuizPreviewController extends GetxController { title: 'Error', message: 'Jumlah soal harus 10 atau lebih', ); + return; } @@ -124,9 +125,11 @@ class QuizPreviewController extends GetxController { message: 'Kuis berhasil disimpan!', ); + CustomFloatingLoading.hideLoading(); Get.offAllNamed(AppRoutes.mainPage, arguments: 2); } } catch (e) { + CustomFloatingLoading.hideLoading(); logC.e(e); } finally { isLoading.value = false; diff --git a/lib/feature/room_maker/controller/room_maker_controller.dart b/lib/feature/room_maker/controller/room_maker_controller.dart index c64c2ac..a073204 100644 --- a/lib/feature/room_maker/controller/room_maker_controller.dart +++ b/lib/feature/room_maker/controller/room_maker_controller.dart @@ -106,6 +106,14 @@ class RoomMakerController extends GetxController { return; } + if (int.tryParse(maxPlayerTC.text) == null) { + CustomNotification.error( + title: "Input tidak valid", + message: "Jumlah pemain harus berupa angka tanpa karakter huruf atau simbol.", + ); + return; + } + if (!await _connectionService.isHaveConnection()) { ConnectionNotification.noInternedConnection(); return; From 0a138793ae11a6b968e4c594cf155b2521a32c05 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Thu, 12 Jun 2025 04:11:25 +0700 Subject: [PATCH 104/104] fix: adding back limitation --- lib/core/utils/custom_floating_loading.dart | 4 +- .../history/view/detail_history_view.dart | 20 +- .../controller/join_room_controller.dart | 8 + .../join_room/view/join_room_view.dart | 330 +++++++++--------- .../login/controllers/login_controller.dart | 12 + lib/feature/login/view/login_page.dart | 130 +++---- .../controller/profile_controller.dart | 5 +- .../controller/update_profile_controller.dart | 9 + .../profile/view/update_profile_view.dart | 104 +++--- .../controller/quiz_creation_controller.dart | 51 ++- .../view/quiz_creation_view.dart | 56 +-- .../controller/quiz_preview_controller.dart | 4 + .../component/subject_dropdown_component.dart | 1 + .../quiz_preview/view/quiz_preview.dart | 18 +- .../controller/register_controller.dart | 5 + 15 files changed, 421 insertions(+), 336 deletions(-) diff --git a/lib/core/utils/custom_floating_loading.dart b/lib/core/utils/custom_floating_loading.dart index 6ea9a5d..0bf1aa4 100644 --- a/lib/core/utils/custom_floating_loading.dart +++ b/lib/core/utils/custom_floating_loading.dart @@ -14,7 +14,9 @@ class CustomFloatingLoading { color: Colors.black.withValues(alpha: 0.5), ), const Center( - child: CircularProgressIndicator(), + child: CircularProgressIndicator( + color: Colors.white, + ), ), ], ), diff --git a/lib/feature/history/view/detail_history_view.dart b/lib/feature/history/view/detail_history_view.dart index 9818076..1d8999d 100644 --- a/lib/feature/history/view/detail_history_view.dart +++ b/lib/feature/history/view/detail_history_view.dart @@ -43,15 +43,17 @@ class DetailHistoryView extends GetView { List quizListings() { return controller.quizAnswer.questionListings - .map((e) => QuizItemWAComponent( - index: e.index, - isCorrect: e.isCorrect, - question: e.question, - targetAnswer: e.targetAnswer, - timeSpent: e.timeSpent, - type: e.type, - userAnswer: e.userAnswer, - options: e.options, + .asMap() + .entries + .map((entry) => QuizItemWAComponent( + index: entry.key + 1, + isCorrect: entry.value.isCorrect, + question: entry.value.question, + targetAnswer: entry.value.targetAnswer, + timeSpent: entry.value.timeSpent, + type: entry.value.type, + userAnswer: entry.value.userAnswer, + options: entry.value.options, )) .toList(); } diff --git a/lib/feature/join_room/controller/join_room_controller.dart b/lib/feature/join_room/controller/join_room_controller.dart index c03e583..65d6c42 100644 --- a/lib/feature/join_room/controller/join_room_controller.dart +++ b/lib/feature/join_room/controller/join_room_controller.dart @@ -24,6 +24,7 @@ class JoinRoomController extends GetxController { ); final TextEditingController codeController = TextEditingController(); + RxBool isLoading = false.obs; void joinRoom(BuildContext context) { if (!_connectionService.isCurrentlyConnected) { @@ -42,12 +43,14 @@ class JoinRoomController extends GetxController { return; } CustomFloatingLoading.showLoading(Get.overlayContext!); + isLoading.value = true; _socketService.initSocketConnection(); _socketService.joinRoom(sessionCode: code, userId: _userController.userData!.id); _socketService.errors.listen((error) { CustomNotification.error(title: "not found", message: "Ruangan tidak ditemukan"); CustomFloatingLoading.hideLoading(); + isLoading.value = false; }); _socketService.roomMessages.listen((data) { @@ -57,6 +60,7 @@ class JoinRoomController extends GetxController { final Map quizInfoJson = dataPayload["quiz_info"]; CustomFloatingLoading.hideLoading(); + isLoading.value = false; Get.toNamed( AppRoutes.waitRoomPage, arguments: WaitingRoomDTO( @@ -73,6 +77,10 @@ class JoinRoomController extends GetxController { }); } + void onGoBack() { + if (!isLoading.value) Get.back(); + } + @override void onClose() { codeController.dispose(); diff --git a/lib/feature/join_room/view/join_room_view.dart b/lib/feature/join_room/view/join_room_view.dart index 7dd1777..b37330f 100644 --- a/lib/feature/join_room/view/join_room_view.dart +++ b/lib/feature/join_room/view/join_room_view.dart @@ -12,185 +12,189 @@ class JoinRoomView extends GetView { @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.white, - extendBodyBehindAppBar: true, - appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - leading: IconButton( - icon: const Icon(LucideIcons.arrowLeft, color: Colors.black87), - onPressed: () => Get.back(), + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) => controller.onGoBack(), + child: Scaffold( + backgroundColor: Colors.white, + extendBodyBehindAppBar: true, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const Icon(LucideIcons.arrowLeft, color: Colors.black87), + onPressed: () => Get.back(), + ), ), - ), - body: Container( - color: Colors.white, - child: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const SizedBox(height: 20), + body: Container( + color: Colors.white, + child: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 20), - // TweenAnimationBuilder( - // duration: const Duration(seconds: 1), - // tween: Tween(begin: 0.0, end: 1.0), - // builder: (context, value, child) { - // return Transform.scale( - // scale: value, - // child: child, - // ); - // }, - // child: Container( - // padding: EdgeInsets.all(22), - // decoration: BoxDecoration( - // color: AppColors.primaryBlue.withValues(alpha: 0.05), - // shape: BoxShape.circle, - // border: Border.all( - // color: AppColors.primaryBlue.withValues(alpha: 0.15), - // width: 2, - // ), - // ), - // child: Icon( - // LucideIcons.trophy, - // size: 70, - // color: AppColors.primaryBlue, - // ), - // ), - // ), + // TweenAnimationBuilder( + // duration: const Duration(seconds: 1), + // tween: Tween(begin: 0.0, end: 1.0), + // builder: (context, value, child) { + // return Transform.scale( + // scale: value, + // child: child, + // ); + // }, + // child: Container( + // padding: EdgeInsets.all(22), + // decoration: BoxDecoration( + // color: AppColors.primaryBlue.withValues(alpha: 0.05), + // shape: BoxShape.circle, + // border: Border.all( + // color: AppColors.primaryBlue.withValues(alpha: 0.15), + // width: 2, + // ), + // ), + // child: Icon( + // LucideIcons.trophy, + // size: 70, + // color: AppColors.primaryBlue, + // ), + // ), + // ), - const SizedBox(height: 30), + const SizedBox(height: 30), - TweenAnimationBuilder( - duration: const Duration(milliseconds: 800), - tween: Tween(begin: 0.0, end: 1.0), - builder: (context, value, child) { - return Opacity( - opacity: value, - child: Transform.translate( - offset: Offset(0, 20 * (1 - value)), - child: child, - ), - ); - }, - child: Text( - context.tr("ready_to_compete"), - style: const TextStyle( - fontSize: 28, - fontWeight: FontWeight.bold, - color: Colors.black87, - ), - textAlign: TextAlign.center, - ), - ), - - const SizedBox(height: 15), - - // Animated Subtitle - TweenAnimationBuilder( - duration: const Duration(milliseconds: 800), - tween: Tween(begin: 0.0, end: 1.0), - builder: (context, value, child) { - return Opacity( - opacity: value, - child: Transform.translate( - offset: Offset(0, 20 * (1 - value)), - child: child, - ), - ); - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), + TweenAnimationBuilder( + duration: const Duration(milliseconds: 800), + tween: Tween(begin: 0.0, end: 1.0), + builder: (context, value, child) { + return Opacity( + opacity: value, + child: Transform.translate( + offset: Offset(0, 20 * (1 - value)), + child: child, + ), + ); + }, child: Text( - context.tr("enter_code_to_join"), + context.tr("ready_to_compete"), style: const TextStyle( - fontSize: 16, - color: Colors.black54, - height: 1.4, + fontSize: 28, + fontWeight: FontWeight.bold, + color: Colors.black87, ), textAlign: TextAlign.center, ), ), - ), - const SizedBox(height: 40), + const SizedBox(height: 15), - TweenAnimationBuilder( - duration: const Duration(milliseconds: 1000), - tween: Tween(begin: 0.0, end: 1.0), - builder: (context, value, child) { - return Opacity( - opacity: value, - child: Transform.translate( - offset: Offset(0, 30 * (1 - value)), - child: child, + // Animated Subtitle + TweenAnimationBuilder( + duration: const Duration(milliseconds: 800), + tween: Tween(begin: 0.0, end: 1.0), + builder: (context, value, child) { + return Opacity( + opacity: value, + child: Transform.translate( + offset: Offset(0, 20 * (1 - value)), + child: child, + ), + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text( + context.tr("enter_code_to_join"), + style: const TextStyle( + fontSize: 16, + color: Colors.black54, + height: 1.4, + ), + textAlign: TextAlign.center, ), - ); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 30), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(24), - boxShadow: [ - BoxShadow( - color: Colors.grey.withValues(alpha: 0.08), - blurRadius: 15, - offset: const Offset(0, 5), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - LucideIcons.keySquare, - color: AppColors.primaryBlue, - size: 24, - ), - const SizedBox(width: 12), - Text( - context.tr("enter_room_code"), - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w600, - color: Colors.black87, - ), - ), - ], - ), - const SizedBox(height: 25), - Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: Colors.grey.shade200, - width: 1, - ), - ), - child: GlobalTextField( - controller: controller.codeController, - hintText: context.tr("room_code_hint"), - textInputType: TextInputType.text, - forceUpperCase: true, - ), - ), - const SizedBox(height: 30), - GlobalButton( - text: context.tr("join_quiz_now"), - onPressed: () => controller.joinRoom(context), - ), - ], ), ), - ), - const SizedBox(height: 30), - ], + const SizedBox(height: 40), + + TweenAnimationBuilder( + duration: const Duration(milliseconds: 1000), + tween: Tween(begin: 0.0, end: 1.0), + builder: (context, value, child) { + return Opacity( + opacity: value, + child: Transform.translate( + offset: Offset(0, 30 * (1 - value)), + child: child, + ), + ); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 30), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: Colors.grey.withValues(alpha: 0.08), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + LucideIcons.keySquare, + color: AppColors.primaryBlue, + size: 24, + ), + const SizedBox(width: 12), + Text( + context.tr("enter_room_code"), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + ], + ), + const SizedBox(height: 25), + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.grey.shade200, + width: 1, + ), + ), + child: GlobalTextField( + controller: controller.codeController, + hintText: context.tr("room_code_hint"), + textInputType: TextInputType.text, + forceUpperCase: true, + ), + ), + const SizedBox(height: 30), + GlobalButton( + text: context.tr("join_quiz_now"), + onPressed: () => controller.joinRoom(context), + ), + ], + ), + ), + ), + + const SizedBox(height: 30), + ], + ), ), ), ), diff --git a/lib/feature/login/controllers/login_controller.dart b/lib/feature/login/controllers/login_controller.dart index 71f66e2..c1cb0f8 100644 --- a/lib/feature/login/controllers/login_controller.dart +++ b/lib/feature/login/controllers/login_controller.dart @@ -102,10 +102,12 @@ class LoginController extends GetxController { _userController.setUserFromEntity(userEntity); _userStorageService.isLogged = true; CustomFloatingLoading.hideLoading(); + isLoading.value = false; Get.offAllNamed(AppRoutes.mainPage); } catch (e, stackTrace) { logC.e(e, stackTrace: stackTrace); CustomFloatingLoading.hideLoading(); + isLoading.value = false; CustomNotification.error(title: "Gagal", message: "Periksa kembali email dan kata sandi Anda"); } } @@ -117,11 +119,13 @@ class LoginController extends GetxController { } try { CustomFloatingLoading.showLoading(Get.overlayContext!); + isLoading.value = true; final user = await _googleAuthService.signIn(); if (user == null) { Get.snackbar("Kesalahan", "Masuk dengan Google dibatalkan"); CustomFloatingLoading.hideLoading(); + isLoading.value = false; return; } @@ -130,6 +134,7 @@ class LoginController extends GetxController { Get.snackbar("Kesalahan", "Tidak menerima ID Token dari Google"); CustomFloatingLoading.hideLoading(); + isLoading.value = false; return; } @@ -140,13 +145,20 @@ class LoginController extends GetxController { _userController.setUserFromEntity(userEntity); _userStorageService.isLogged = true; CustomFloatingLoading.hideLoading(); + isLoading.value = false; Get.offAllNamed(AppRoutes.mainPage); } catch (e, stackTrace) { logC.e("Google Sign-In Error: $e", stackTrace: stackTrace); Get.snackbar("Error", "Google sign-in error"); + CustomFloatingLoading.hideLoading(); + isLoading.value = false; } } + void onGoBack() { + if (!isLoading.value) Get.back(); + } + void goToRegsPage() => Get.toNamed(AppRoutes.registerPage); UserEntity _convertLoginResponseToUserEntity(LoginResponseModel response) { diff --git a/lib/feature/login/view/login_page.dart b/lib/feature/login/view/login_page.dart index 2011f91..30633eb 100644 --- a/lib/feature/login/view/login_page.dart +++ b/lib/feature/login/view/login_page.dart @@ -15,70 +15,74 @@ class LoginView extends GetView { @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.background, - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), - child: ListView( - children: [ - const SizedBox(height: 40), - const AppName(), - const SizedBox(height: 40), - LabelTextField( - label: context.tr("log_in"), - fontSize: 28, - fontWeight: FontWeight.bold, - color: Color(0xFF172B4D), - ), - const SizedBox(height: 24), - LabelTextField( - label: context.tr("email"), - color: Color(0xFF6B778C), - fontSize: 14, - ), - const SizedBox(height: 6), - GlobalTextField( - controller: controller.emailController, - hintText: context.tr("enter_your_email"), - ), - const SizedBox(height: 20), - LabelTextField( - label: context.tr("password"), - color: Color(0xFF6B778C), - fontSize: 14, - ), - const SizedBox(height: 6), - Obx( - () => GlobalTextField( - controller: controller.passwordController, - isPassword: true, - obscureText: controller.isPasswordHidden.value, - onToggleVisibility: controller.togglePasswordVisibility, - hintText: context.tr("enter_your_password"), + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) => controller.onGoBack(), + child: Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: ListView( + children: [ + const SizedBox(height: 40), + const AppName(), + const SizedBox(height: 40), + LabelTextField( + label: context.tr("log_in"), + fontSize: 28, + fontWeight: FontWeight.bold, + color: Color(0xFF172B4D), ), - ), - const SizedBox(height: 32), - Obx(() => GlobalButton( - onPressed: controller.loginWithEmail, - text: context.tr("sign_in"), - type: controller.isButtonEnabled.value, - )), - const SizedBox(height: 24), - LabelTextField( - label: context.tr("or"), - alignment: Alignment.center, - color: Color(0xFF6B778C), - ), - const SizedBox(height: 24), - GoogleButton( - onPress: controller.loginWithGoogle, - ), - const SizedBox(height: 32), - RegisterTextButton( - onTap: controller.goToRegsPage, - ), - ], + const SizedBox(height: 24), + LabelTextField( + label: context.tr("email"), + color: Color(0xFF6B778C), + fontSize: 14, + ), + const SizedBox(height: 6), + GlobalTextField( + controller: controller.emailController, + hintText: context.tr("enter_your_email"), + ), + const SizedBox(height: 20), + LabelTextField( + label: context.tr("password"), + color: Color(0xFF6B778C), + fontSize: 14, + ), + const SizedBox(height: 6), + Obx( + () => GlobalTextField( + controller: controller.passwordController, + isPassword: true, + obscureText: controller.isPasswordHidden.value, + onToggleVisibility: controller.togglePasswordVisibility, + hintText: context.tr("enter_your_password"), + ), + ), + const SizedBox(height: 32), + Obx(() => GlobalButton( + onPressed: controller.loginWithEmail, + text: context.tr("sign_in"), + type: controller.isButtonEnabled.value, + )), + const SizedBox(height: 24), + LabelTextField( + label: context.tr("or"), + alignment: Alignment.center, + color: Color(0xFF6B778C), + ), + const SizedBox(height: 24), + GoogleButton( + onPress: controller.loginWithGoogle, + ), + const SizedBox(height: 32), + RegisterTextButton( + onTap: controller.goToRegsPage, + ), + ], + ), ), ), ), diff --git a/lib/feature/profile/controller/profile_controller.dart b/lib/feature/profile/controller/profile_controller.dart index cf08049..4fd78d8 100644 --- a/lib/feature/profile/controller/profile_controller.dart +++ b/lib/feature/profile/controller/profile_controller.dart @@ -71,7 +71,6 @@ class ProfileController extends GetxController { birthDate.value = _userController.userData?.birthDate ?? ""; phoneNumber.value = _userController.userData?.phone ?? ""; joinDate.value = _userController.userData?.createdAt ?? ""; - } catch (e, stackTrace) { logC.e("Failed to load user profile data: $e", stackTrace: stackTrace); } @@ -114,9 +113,9 @@ class ProfileController extends GetxController { } void editProfile() async { - final bool resultUpdate = await Get.toNamed(AppRoutes.updateProfilePage); + final resultUpdate = await Get.toNamed(AppRoutes.updateProfilePage); - if (resultUpdate) { + if (resultUpdate == true) { loadUserProfileData(); } } diff --git a/lib/feature/profile/controller/update_profile_controller.dart b/lib/feature/profile/controller/update_profile_controller.dart index 57d4e9b..f8beac4 100644 --- a/lib/feature/profile/controller/update_profile_controller.dart +++ b/lib/feature/profile/controller/update_profile_controller.dart @@ -29,6 +29,8 @@ class UpdateProfileController extends GetxController { var selectedLocale = 'en-US'.obs; + RxBool isLoading = false.obs; + final Map localeMap = { 'English': 'en-US', 'Indonesian': 'id-ID', @@ -78,6 +80,7 @@ class UpdateProfileController extends GetxController { try { CustomFloatingLoading.showLoading(Get.overlayContext!); + isLoading.value = true; final isSuccessUpdate = await _userService.updateProfileData( _userController.userData!.id, nameController.text.trim(), @@ -111,11 +114,13 @@ class UpdateProfileController extends GetxController { } } CustomFloatingLoading.hideLoading(); + isLoading.value = false; Get.back(result: true); CustomNotification.success(title: "Success", message: "Profile updated successfully"); } catch (e) { CustomNotification.success(title: "something wrong", message: "failed to update profile"); + isLoading.value = false; logC.e(e); } } @@ -124,4 +129,8 @@ class UpdateProfileController extends GetxController { final regex = RegExp(r'^([0-2][0-9]|(3)[0-1])\-((0[1-9])|(1[0-2]))\-\d{4}$'); return regex.hasMatch(date); } + + void onGoBack() { + if (!isLoading.value) Get.back(); + } } diff --git a/lib/feature/profile/view/update_profile_view.dart b/lib/feature/profile/view/update_profile_view.dart index 8d80650..291c32e 100644 --- a/lib/feature/profile/view/update_profile_view.dart +++ b/lib/feature/profile/view/update_profile_view.dart @@ -11,58 +11,62 @@ import 'package:quiz_app/feature/profile/controller/update_profile_controller.da class UpdateProfilePage extends GetView { @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.background2, - appBar: AppBar( + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) => controller.onGoBack(), + child: Scaffold( backgroundColor: AppColors.background2, - title: Text('Update Profile'), - centerTitle: true, - ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: ListView( - children: [ - LabelTextField(label: "Name"), - GlobalTextField(controller: controller.nameController), - SizedBox(height: 16), - LabelTextField(label: "Phone"), - GlobalTextField( - controller: controller.phoneController, - hintText: 'Enter your phone number', - ), - SizedBox(height: 16), - LabelTextField(label: "Birth Date"), - GlobalTextField( - controller: controller.birthDateController, - hintText: 'Enter your birth date', - ), - SizedBox(height: 16), - LabelTextField(label: "Locale"), - Obx(() => GlobalDropdownField( - value: controller.selectedLocale.value, - items: controller.localeMap.entries.map>((entry) { - return DropdownMenuItem( - value: entry.value, - child: Text(entry.key), // Display country name - ); - }).toList(), - onChanged: (String? newValue) { - if (newValue != null) { - controller.selectedLocale.value = newValue; - final parts = newValue.split('-'); - if (parts.length == 2) { - Get.updateLocale(Locale(parts[0], parts[1])); - } else { - Get.updateLocale(Locale(newValue)); + appBar: AppBar( + backgroundColor: AppColors.background2, + title: Text('Update Profile'), + centerTitle: true, + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: ListView( + children: [ + LabelTextField(label: "Name"), + GlobalTextField(controller: controller.nameController), + SizedBox(height: 16), + LabelTextField(label: "Phone"), + GlobalTextField( + controller: controller.phoneController, + hintText: 'Enter your phone number', + ), + SizedBox(height: 16), + LabelTextField(label: "Birth Date"), + GlobalTextField( + controller: controller.birthDateController, + hintText: 'Enter your birth date', + ), + SizedBox(height: 16), + LabelTextField(label: "Locale"), + Obx(() => GlobalDropdownField( + value: controller.selectedLocale.value, + items: controller.localeMap.entries.map>((entry) { + return DropdownMenuItem( + value: entry.value, + child: Text(entry.key), // Display country name + ); + }).toList(), + onChanged: (String? newValue) { + if (newValue != null) { + controller.selectedLocale.value = newValue; + final parts = newValue.split('-'); + if (parts.length == 2) { + Get.updateLocale(Locale(parts[0], parts[1])); + } else { + Get.updateLocale(Locale(newValue)); + } } - } - }, - )), - SizedBox(height: 32), - Center( - child: GlobalButton(text: tr("save_changes"), onPressed: controller.saveProfile), - ), - ], + }, + )), + SizedBox(height: 32), + Center( + child: GlobalButton(text: tr("save_changes"), onPressed: controller.saveProfile), + ), + ], + ), ), ), ); diff --git a/lib/feature/quiz_creation/controller/quiz_creation_controller.dart b/lib/feature/quiz_creation/controller/quiz_creation_controller.dart index ad9e2ec..c33a4ab 100644 --- a/lib/feature/quiz_creation/controller/quiz_creation_controller.dart +++ b/lib/feature/quiz_creation/controller/quiz_creation_controller.dart @@ -35,6 +35,8 @@ class QuizCreationController extends GetxController { RxInt currentDuration = 30.obs; + RxBool isLoading = false.obs; + @override void onInit() { super.onInit(); @@ -199,7 +201,7 @@ class QuizCreationController extends GetxController { void onBack(BuildContext context) { if (quizData.length <= 1) { - Navigator.pop(context); + Get.back(); } else { AppDialog.showExitConfirmationDialog(context); } @@ -240,50 +242,67 @@ class QuizCreationController extends GetxController { return; } CustomFloatingLoading.showLoading(Get.overlayContext!); + isLoading.value = true; try { BaseResponseModel> response = await _quizService.createQuizAuto(inputSentenceTC.text); - if (response.data != null) { - final previousLength = quizData.length; + if (response.data != null && response.data!.isNotEmpty) { + // Check if we should remove the initial empty question + bool shouldRemoveInitial = quizData.length == 1 && quizData[0].question == null && quizData[0].answer == null; - if (previousLength == 1) quizData.removeAt(0); + if (shouldRemoveInitial) { + quizData.clear(); + } - for (final i in response.data!) { + // Add new questions + for (final quizItem in response.data!) { QuestionType type = QuestionType.fillTheBlank; - if (i.answer.toString().toLowerCase() == 'true' || i.answer.toString().toLowerCase() == 'false') { + if (quizItem.answer.toString().toLowerCase() == 'true' || quizItem.answer.toString().toLowerCase() == 'false') { type = QuestionType.trueOrFalse; } quizData.add(QuestionData( index: quizData.length + 1, - question: i.qustion, - answer: i.answer, + question: quizItem.qustion, + answer: quizItem.answer, type: type, )); } - if (response.data!.isNotEmpty) { - selectedQuizIndex.value = previousLength; + // Set the selected index to the first newly added question + if (shouldRemoveInitial) { + selectedQuizIndex.value = 0; + } else { + // If we didn't remove initial data, select the first new question + selectedQuizIndex.value = quizData.length - response.data!.length; + } + + // Update UI with the selected question data + if (selectedQuizIndex.value < quizData.length) { final data = quizData[selectedQuizIndex.value]; questionTC.text = data.question ?? ""; answerTC.text = data.answer ?? ""; currentDuration.value = data.duration; currentQuestionType.value = data.type ?? QuestionType.fillTheBlank; - return; } } } catch (e) { logC.e("Error while generating quiz: $e"); + CustomFloatingLoading.hideLoading(); } finally { CustomFloatingLoading.hideLoading(); + isLoading.value = false; isGenerate.value = false; - inputSentenceTC.text = ""; - if (quizData.isNotEmpty && selectedQuizIndex.value == 0) { - final data = quizData[0]; + if (quizData.isNotEmpty && selectedQuizIndex.value >= quizData.length) { + selectedQuizIndex.value = 0; + } + + if (quizData.isNotEmpty) { + final data = quizData[selectedQuizIndex.value]; questionTC.text = data.question ?? ""; answerTC.text = data.answer ?? ""; currentDuration.value = data.duration; @@ -291,4 +310,8 @@ class QuizCreationController extends GetxController { } } } + + onGoBack(BuildContext context, bool didPop) { + if (!isLoading.value) onBack(context); + } } diff --git a/lib/feature/quiz_creation/view/quiz_creation_view.dart b/lib/feature/quiz_creation/view/quiz_creation_view.dart index 114db26..b687139 100644 --- a/lib/feature/quiz_creation/view/quiz_creation_view.dart +++ b/lib/feature/quiz_creation/view/quiz_creation_view.dart @@ -11,35 +11,39 @@ class QuizCreationView extends GetView { @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.background, - appBar: AppBar( + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) => controller.onGoBack(context, didPop), + child: Scaffold( backgroundColor: AppColors.background, - elevation: 0, - title: Text( - context.tr('create_quiz_title'), - style: const TextStyle( - fontWeight: FontWeight.bold, - color: AppColors.darkText, + appBar: AppBar( + backgroundColor: AppColors.background, + elevation: 0, + title: Text( + context.tr('create_quiz_title'), + style: const TextStyle( + fontWeight: FontWeight.bold, + color: AppColors.darkText, + ), ), + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new_rounded, color: AppColors.darkText), + onPressed: () => controller.onBack(context), + ), + centerTitle: true, ), - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios_new_rounded, color: AppColors.darkText), - onPressed: () => controller.onBack(context), - ), - centerTitle: true, - ), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(20.0), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildModeSelector(context), - const SizedBox(height: 20), - Obx(() => controller.isGenerate.value ? const GenerateComponent() : const CustomQuestionComponent()), - ], + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildModeSelector(context), + const SizedBox(height: 20), + Obx(() => controller.isGenerate.value ? const GenerateComponent() : const CustomQuestionComponent()), + ], + ), ), ), ), diff --git a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart index 08f4711..e4871be 100644 --- a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart +++ b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart @@ -180,6 +180,10 @@ class QuizPreviewController extends GetxController { subjectIndex.value = index; } + void onBack() { + if (!isLoading.value) Get.back(); + } + @override void onClose() { titleController.dispose(); diff --git a/lib/feature/quiz_preview/view/component/subject_dropdown_component.dart b/lib/feature/quiz_preview/view/component/subject_dropdown_component.dart index 8e982f9..123cd9f 100644 --- a/lib/feature/quiz_preview/view/component/subject_dropdown_component.dart +++ b/lib/feature/quiz_preview/view/component/subject_dropdown_component.dart @@ -34,6 +34,7 @@ class SubjectDropdownComponent extends StatelessWidget { } } }, + dropdownColor: Colors.white, decoration: InputDecoration( filled: true, fillColor: Colors.white, diff --git a/lib/feature/quiz_preview/view/quiz_preview.dart b/lib/feature/quiz_preview/view/quiz_preview.dart index 668263c..f5cdf4f 100644 --- a/lib/feature/quiz_preview/view/quiz_preview.dart +++ b/lib/feature/quiz_preview/view/quiz_preview.dart @@ -14,13 +14,17 @@ class QuizPreviewPage extends GetView { @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.background, - appBar: _buildAppBar(context), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(20.0), - child: _buildContent(context), + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) => controller.onBack(), + child: Scaffold( + backgroundColor: AppColors.background, + appBar: _buildAppBar(context), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: _buildContent(context), + ), ), ), ); diff --git a/lib/feature/register/controller/register_controller.dart b/lib/feature/register/controller/register_controller.dart index 09db64a..b31810a 100644 --- a/lib/feature/register/controller/register_controller.dart +++ b/lib/feature/register/controller/register_controller.dart @@ -26,6 +26,8 @@ class RegisterController extends GetxController { var isPasswordHidden = true.obs; var isConfirmPasswordHidden = true.obs; + RxBool isLoading = false.obs; + @override void onReady() { if (!_connectionService.isCurrentlyConnected) { @@ -82,6 +84,7 @@ class RegisterController extends GetxController { try { CustomFloatingLoading.showLoading(Get.overlayContext!); + isLoading.value = true; await _authService.register( RegisterRequestModel( email: email, @@ -94,9 +97,11 @@ class RegisterController extends GetxController { Get.back(); CustomFloatingLoading.hideLoading(); + isLoading.value = false; CustomNotification.success(title: "Pendaftaran Berhasil", message: "Akun berhasil dibuat"); } catch (e) { CustomFloatingLoading.hideLoading(); + isLoading.value = false; String errorMessage = e.toString().replaceFirst("Exception: ", "");