- feat(learn): Integrate learn practice with local data

Merge branch 'release/0.1.0-internal.v2'
This commit is contained in:
BisratHailu 2026-03-30 17:23:23 +03:00
commit 177b315f95
314 changed files with 14653 additions and 2790 deletions

View File

@ -1,41 +1,77 @@
import java.util.Properties
import java.io.FileInputStream
plugins { plugins {
id("kotlin-android")
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.gms.google-services") id("com.google.gms.google-services")
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
} }
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
FileInputStream(keystorePropertiesFile).use {
keystoreProperties.load(it)
}
}
android { android {
ndkVersion = flutter.ndkVersion
namespace = "com.yimaru.lms.app" namespace = "com.yimaru.lms.app"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions { compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
defaultConfig { defaultConfig {
minSdk = flutter.minSdkVersion minSdk = flutter.minSdkVersion
applicationId = "com.yimaru.lms.app"
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
applicationId = "com.yimaru.lms.app"
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
} }
buildTypes { signingConfigs {
release { create("release") {
signingConfig = signingConfigs.getByName("debug") keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storePassword = keystoreProperties["storePassword"] as String
storeFile = keystoreProperties["storeFile"]?.let { file(it as String) }
} }
} }
buildTypes {
getByName("release") {
isMinifyEnabled = false
isShrinkResources = false
ndk { debugSymbolLevel = "FULL" }
signingConfig = signingConfigs.getByName("release")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
)
}
}
}
kotlin {
jvmToolchain(17)
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
implementation(platform("com.google.firebase:firebase-bom:34.10.0"))
} }
flutter { flutter {

View File

@ -14,20 +14,20 @@
}, },
"oauth_client": [ "oauth_client": [
{ {
"client_id": "574860813475-01gh5tk0bu5bgj68r02sgh5pk5greoku.apps.googleusercontent.com", "client_id": "574860813475-3p3k63lkrfd113sn6jscgvdj0aigsg5s.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "com.yimaru.lms.app",
"certificate_hash": "fc91f52846d27c62bba3e16bc98982fb9953eca1"
}
},
{
"client_id": "574860813475-631s3mo8ha2qc2jeb5e2aosn0967niik.apps.googleusercontent.com",
"client_type": 1, "client_type": 1,
"android_info": { "android_info": {
"package_name": "com.yimaru.lms.app", "package_name": "com.yimaru.lms.app",
"certificate_hash": "928ead08b5e39d6a861a55ae7cceb8c402d1ee7a" "certificate_hash": "928ead08b5e39d6a861a55ae7cceb8c402d1ee7a"
} }
},
{
"client_id": "574860813475-m90u87plqaac4tb8oug32k41usossiod.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "com.yimaru.lms.app",
"certificate_hash": "29797902ad6a24212b9d9fad71562907956f6a6c"
}
} }
], ],
"api_key": [ "api_key": [

41
android/app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,41 @@
############################################
# Flutter
############################################
-keep class io.flutter.** { *; }
-keep class io.flutter.plugins.** { *; }
############################################
# Firebase (General Safe Rules)
############################################
-keep class com.google.firebase.** { *; }
-dontwarn com.google.firebase.**
############################################
# Google Sign-In
############################################
-keep class com.google.android.gms.auth.api.signin.** { *; }
-keep class com.google.android.gms.common.api.** { *; }
-dontwarn com.google.android.gms.**
############################################
# Play Services
############################################
-keep class com.google.android.gms.** { *; }
############################################
# flutter_inappwebview
############################################
-keep class com.pichillilorenzo.flutter_inappwebview.** { *; }
-dontwarn com.pichillilorenzo.flutter_inappwebview.**
############################################
# Keep annotations
############################################
-keepattributes *Annotation*
############################################
# Google Play Core
############################################
-keep class com.google.android.play.core.** { *; }
-dontwarn com.google.android.play.core.**

View File

@ -7,7 +7,7 @@
<uses-permission android:name="android.permission.CAMERA"/> <uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<application <application
android:label="Yimaru" android:label="Yimaru Academy"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@ -1,12 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" /> <item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
<!-- You can insert your own image assets here --> </item>
<!-- <item> <item>
<bitmap <bitmap android:gravity="center" android:src="@drawable/splash"/>
android:gravity="center" </item>
android:src="@mipmap/launch_image" />
</item> -->
</layer-list> </layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@ -1,12 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" /> <item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
<!-- You can insert your own image assets here --> </item>
<!-- <item> <item>
<bitmap <bitmap android:gravity="center" android:src="@drawable/splash"/>
android:gravity="center" </item>
android:src="@mipmap/launch_image" />
</item> -->
</layer-list> </layer-list>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#9E2891</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -5,6 +5,10 @@
<!-- Show a splash screen on the activity. Automatically removed when <!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame --> the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item> <item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style> </style>
<!-- Theme applied to the Android Window as soon as the process has started. <!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your This theme determines the color of the Android Window while your

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#9E2891</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -5,6 +5,10 @@
<!-- Show a splash screen on the activity. Automatically removed when <!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame --> the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item> <item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style> </style>
<!-- Theme applied to the Android Window as soon as the process has started. <!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your This theme determines the color of the Android Window while your

File diff suppressed because one or more lines are too long

View File

@ -1,2 +1,12 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true android.useAndroidX=true
android.defaults.buildfeatures.resvalues=true
android.sdk.defaultTargetSdkToCompileSdkIfUnset=false
android.enableAppCompileTimeRClass=false
android.usesSdkInManifest.disallowed=false
android.uniquePackageNames=false
android.dependency.useConstraints=true
android.r8.strictFullModeForKeepRules=false
android.r8.optimizedResourceShrinking=false
android.builtInKotlin=false
android.newDsl=false

View File

@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip

View File

@ -19,9 +19,10 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.13.2" apply false id("com.android.application") version "9.1.0" apply false
id("org.jetbrains.kotlin.android") version "2.3.0" apply false id("org.jetbrains.kotlin.android") version "2.3.0" apply false
id("com.google.gms.google-services") version("4.4.4") apply false id("com.google.gms.google-services") version("4.4.4") apply false
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
} }

BIN
assets/icons/duolingo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

BIN
assets/icons/dwarf.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

3
devtools_options.yaml Normal file
View File

@ -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:

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "background.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@ -1,23 +1,23 @@
{ {
"images" : [ "images" : [
{ {
"idiom" : "universal",
"filename" : "LaunchImage.png", "filename" : "LaunchImage.png",
"idiom" : "universal",
"scale" : "1x" "scale" : "1x"
}, },
{ {
"idiom" : "universal",
"filename" : "LaunchImage@2x.png", "filename" : "LaunchImage@2x.png",
"idiom" : "universal",
"scale" : "2x" "scale" : "2x"
}, },
{ {
"idiom" : "universal",
"filename" : "LaunchImage@3x.png", "filename" : "LaunchImage@3x.png",
"idiom" : "universal",
"scale" : "3x" "scale" : "3x"
} }
], ],
"info" : { "info" : {
"version" : 1, "author" : "xcode",
"author" : "xcode" "version" : 1
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 57 KiB

View File

@ -16,13 +16,19 @@
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3"> <view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews> <subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"> <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/>
</imageView> <imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"></imageView>
</subviews> </subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints> <constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/> <constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/> <constraint firstItem="tWc-Dq-wcI" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="RPx-PI-7Xg"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="SdS-ul-q2q"/>
<constraint firstAttribute="trailing" secondItem="tWc-Dq-wcI" secondAttribute="trailing" id="Swv-Gf-Rwn"/>
<constraint firstAttribute="trailing" secondItem="YRO-k0-Ey4" secondAttribute="trailing" id="TQA-XW-tRk"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="duK-uY-Gun"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="kV7-tw-vXt"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="xPn-NY-SIU"/>
</constraints> </constraints>
</view> </view>
</viewController> </viewController>
@ -32,6 +38,7 @@
</scene> </scene>
</scenes> </scenes>
<resources> <resources>
<image name="LaunchImage" width="168" height="185"/> <image name="LaunchImage" width="1024" height="1024"/>
<image name="LaunchBackground" width="1" height="1"/>
</resources> </resources>
</document> </document>

View File

@ -1,49 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Yimaru App</string> <string>Yimaru App</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>yimaru_app</string> <string>yimaru_app</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string> <string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</key>
<string>Main</string> <string>Main</string>
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UISupportedInterfaceOrientations~ipad</key> <key>UISupportedInterfaceOrientations~ipad</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string> <string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true/>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
</dict> <key>UIStatusBarHidden</key>
<false/>
</dict>
</plist> </plist>

View File

@ -9,7 +9,6 @@ import 'package:yimaru_app/ui/views/profile/profile_view.dart';
import 'package:yimaru_app/ui/views/profile_detail/profile_detail_view.dart'; import 'package:yimaru_app/ui/views/profile_detail/profile_detail_view.dart';
import 'package:yimaru_app/ui/views/downloads/downloads_view.dart'; import 'package:yimaru_app/ui/views/downloads/downloads_view.dart';
import 'package:yimaru_app/ui/views/progress/progress_view.dart'; import 'package:yimaru_app/ui/views/progress/progress_view.dart';
import 'package:yimaru_app/ui/views/ongoing_progress/ongoing_progress_view.dart';
import 'package:yimaru_app/ui/views/account_privacy/account_privacy_view.dart'; import 'package:yimaru_app/ui/views/account_privacy/account_privacy_view.dart';
import 'package:yimaru_app/ui/views/support/support_view.dart'; import 'package:yimaru_app/ui/views/support/support_view.dart';
import 'package:yimaru_app/ui/views/telegram_support/telegram_support_view.dart'; import 'package:yimaru_app/ui/views/telegram_support/telegram_support_view.dart';
@ -30,7 +29,6 @@ import 'package:yimaru_app/services/status_checker_service.dart';
import 'package:yimaru_app/ui/views/welcome/welcome_view.dart'; import 'package:yimaru_app/ui/views/welcome/welcome_view.dart';
import 'package:yimaru_app/ui/views/assessment/assessment_view.dart'; import 'package:yimaru_app/ui/views/assessment/assessment_view.dart';
import 'package:yimaru_app/ui/views/learn_lesson/learn_lesson_view.dart'; import 'package:yimaru_app/ui/views/learn_lesson/learn_lesson_view.dart';
import 'package:yimaru_app/ui/views/failure/failure_view.dart';
import 'package:yimaru_app/services/permission_handler_service.dart'; import 'package:yimaru_app/services/permission_handler_service.dart';
import 'package:yimaru_app/services/image_picker_service.dart'; import 'package:yimaru_app/services/image_picker_service.dart';
import 'package:yimaru_app/services/google_auth_service.dart'; import 'package:yimaru_app/services/google_auth_service.dart';
@ -38,6 +36,20 @@ import 'package:yimaru_app/services/image_downloader_service.dart';
import 'package:yimaru_app/ui/views/forget_password/forget_password_view.dart'; import 'package:yimaru_app/ui/views/forget_password/forget_password_view.dart';
import 'package:yimaru_app/ui/views/learn_lesson_detail/learn_lesson_detail_view.dart'; import 'package:yimaru_app/ui/views/learn_lesson_detail/learn_lesson_detail_view.dart';
import 'package:yimaru_app/ui/views/learn_practice/learn_practice_view.dart'; import 'package:yimaru_app/ui/views/learn_practice/learn_practice_view.dart';
import 'package:yimaru_app/ui/views/course_practice/course_practice_view.dart';
import 'package:yimaru_app/ui/views/course_payment/course_payment_view.dart';
import 'package:yimaru_app/ui/views/course_category/course_category_view.dart';
import 'package:yimaru_app/ui/views/failure/failure_view.dart';
import 'package:yimaru_app/ui/views/course_lesson/course_lesson_view.dart';
import 'package:yimaru_app/ui/views/course_lesson_detail/course_lesson_detail_view.dart';
import 'package:yimaru_app/services/notification_service.dart';
import 'package:yimaru_app/ui/views/duolingo/duolingo_view.dart';
import 'package:yimaru_app/services/smart_auth_service.dart';
import 'package:yimaru_app/services/course_service.dart';
import 'package:yimaru_app/ui/views/course_subcategory/course_subcategory_view.dart';
import 'package:yimaru_app/ui/views/course/course_view.dart';
import 'package:yimaru_app/services/audio_player_service.dart';
import 'package:yimaru_app/services/voice_recorder_service.dart';
// @stacked-import // @stacked-import
@StackedApp( @StackedApp(
@ -49,7 +61,6 @@ import 'package:yimaru_app/ui/views/learn_practice/learn_practice_view.dart';
MaterialRoute(page: ProfileDetailView), MaterialRoute(page: ProfileDetailView),
MaterialRoute(page: DownloadsView), MaterialRoute(page: DownloadsView),
MaterialRoute(page: ProgressView), MaterialRoute(page: ProgressView),
MaterialRoute(page: OngoingProgressView),
MaterialRoute(page: AccountPrivacyView), MaterialRoute(page: AccountPrivacyView),
MaterialRoute(page: SupportView), MaterialRoute(page: SupportView),
MaterialRoute(page: TelegramSupportView), MaterialRoute(page: TelegramSupportView),
@ -65,10 +76,18 @@ import 'package:yimaru_app/ui/views/learn_practice/learn_practice_view.dart';
MaterialRoute(page: WelcomeView), MaterialRoute(page: WelcomeView),
MaterialRoute(page: AssessmentView), MaterialRoute(page: AssessmentView),
MaterialRoute(page: LearnLessonView), MaterialRoute(page: LearnLessonView),
MaterialRoute(page: FailureView),
MaterialRoute(page: ForgetPasswordView), MaterialRoute(page: ForgetPasswordView),
MaterialRoute(page: LearnLessonDetailView), MaterialRoute(page: LearnLessonDetailView),
MaterialRoute(page: LearnPracticeView), MaterialRoute(page: LearnPracticeView),
MaterialRoute(page: CoursePracticeView),
MaterialRoute(page: CoursePaymentView),
MaterialRoute(page: CourseCategoryView),
MaterialRoute(page: FailureView),
MaterialRoute(page: CourseLessonView),
MaterialRoute(page: CourseLessonDetailView),
MaterialRoute(page: DuolingoView),
MaterialRoute(page: CourseSubcategoryView),
MaterialRoute(page: CourseView),
// @stacked-route // @stacked-route
], ],
dependencies: [ dependencies: [
@ -84,6 +103,11 @@ import 'package:yimaru_app/ui/views/learn_practice/learn_practice_view.dart';
LazySingleton(classType: ImagePickerService), LazySingleton(classType: ImagePickerService),
LazySingleton(classType: GoogleAuthService), LazySingleton(classType: GoogleAuthService),
LazySingleton(classType: ImageDownloaderService), LazySingleton(classType: ImageDownloaderService),
LazySingleton(classType: NotificationService),
LazySingleton(classType: SmartAuthService),
LazySingleton(classType: CourseService),
LazySingleton(classType: AudioPlayerService),
LazySingleton(classType: VoiceRecorderService),
// @stacked-service // @stacked-service
], ],
bottomsheets: [ bottomsheets: [

View File

@ -12,14 +12,19 @@ import 'package:stacked_services/src/navigation/navigation_service.dart';
import 'package:stacked_shared/stacked_shared.dart'; import 'package:stacked_shared/stacked_shared.dart';
import '../services/api_service.dart'; import '../services/api_service.dart';
import '../services/audio_player_service.dart';
import '../services/authentication_service.dart'; import '../services/authentication_service.dart';
import '../services/course_service.dart';
import '../services/dio_service.dart'; import '../services/dio_service.dart';
import '../services/google_auth_service.dart'; import '../services/google_auth_service.dart';
import '../services/image_downloader_service.dart'; import '../services/image_downloader_service.dart';
import '../services/image_picker_service.dart'; import '../services/image_picker_service.dart';
import '../services/notification_service.dart';
import '../services/permission_handler_service.dart'; import '../services/permission_handler_service.dart';
import '../services/secure_storage_service.dart'; import '../services/secure_storage_service.dart';
import '../services/smart_auth_service.dart';
import '../services/status_checker_service.dart'; import '../services/status_checker_service.dart';
import '../services/voice_recorder_service.dart';
final locator = StackedLocator.instance; final locator = StackedLocator.instance;
@ -44,4 +49,9 @@ Future<void> setupLocator({
locator.registerLazySingleton(() => ImagePickerService()); locator.registerLazySingleton(() => ImagePickerService());
locator.registerLazySingleton(() => GoogleAuthService()); locator.registerLazySingleton(() => GoogleAuthService());
locator.registerLazySingleton(() => ImageDownloaderService()); locator.registerLazySingleton(() => ImageDownloaderService());
locator.registerLazySingleton(() => NotificationService());
locator.registerLazySingleton(() => SmartAuthService());
locator.registerLazySingleton(() => CourseService());
locator.registerLazySingleton(() => AudioPlayerService());
locator.registerLazySingleton(() => VoiceRecorderService());
} }

File diff suppressed because it is too large Load Diff

View File

@ -64,7 +64,7 @@ class DefaultFirebaseOptions {
projectId: 'yimaru-lms-e834e', projectId: 'yimaru-lms-e834e',
storageBucket: 'yimaru-lms-e834e.firebasestorage.app', storageBucket: 'yimaru-lms-e834e.firebasestorage.app',
androidClientId: androidClientId:
'574860813475-01gh5tk0bu5bgj68r02sgh5pk5greoku.apps.googleusercontent.com', '574860813475-3p3k63lkrfd113sn6jscgvdj0aigsg5s.apps.googleusercontent.com',
iosBundleId: 'com.yimaru.lms.app', iosBundleId: 'com.yimaru.lms.app',
); );
} }

View File

@ -1,3 +1,4 @@
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:toastification/toastification.dart'; import 'package:toastification/toastification.dart';
import 'package:yimaru_app/app/app.bottomsheets.dart'; import 'package:yimaru_app/app/app.bottomsheets.dart';
@ -5,10 +6,15 @@ import 'package:yimaru_app/app/app.dialogs.dart';
import 'package:yimaru_app/app/app.locator.dart'; import 'package:yimaru_app/app/app.locator.dart';
import 'package:yimaru_app/app/app.router.dart'; import 'package:yimaru_app/app/app.router.dart';
import 'package:stacked_services/stacked_services.dart'; import 'package:stacked_services/stacked_services.dart';
import 'package:yimaru_app/services/notification_service.dart';
import 'firebase_options.dart';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await setupLocator(); await setupLocator();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
await locator<NotificationService>().initialize();
setupDialogUi(); setupDialogUi();
setupBottomSheetUi(); setupBottomSheetUi();
runApp(const MainApp()); runApp(const MainApp());

View File

@ -10,6 +10,8 @@ class Assessment {
final String? status; final String? status;
final List<Option>? options;
@JsonKey(name: 'question_type') @JsonKey(name: 'question_type')
final String? questionType; final String? questionType;
@ -19,8 +21,6 @@ class Assessment {
@JsonKey(name: 'difficulty_level') @JsonKey(name: 'difficulty_level')
final String? difficultyLevel; final String? difficultyLevel;
final List<Option>? options;
const Assessment({ const Assessment({
this.id, this.id,
this.points, this.points,

View File

@ -23,8 +23,8 @@ Map<String, dynamic> _$AssessmentToJson(Assessment instance) =>
'id': instance.id, 'id': instance.id,
'points': instance.points, 'points': instance.points,
'status': instance.status, 'status': instance.status,
'options': instance.options,
'question_type': instance.questionType, 'question_type': instance.questionType,
'question_text': instance.questionText, 'question_text': instance.questionText,
'difficulty_level': instance.difficultyLevel, 'difficulty_level': instance.difficultyLevel,
'options': instance.options,
}; };

39
lib/models/course.dart Normal file
View File

@ -0,0 +1,39 @@
import 'package:json_annotation/json_annotation.dart';
part 'course.g.dart';
@JsonSerializable()
class Course {
final int? id;
final String? level;
final String? title;
final String? thumbnail;
final String? description;
@JsonKey(name: 'course_id')
final int? courseId;
@JsonKey(name: 'is_active')
final bool? isActive;
@JsonKey(name: 'display_order')
final int? displayOrder;
const Course(
{this.id,
this.level,
this.title,
this.isActive,
this.courseId,
this.thumbnail,
this.description,
this.displayOrder});
factory Course.fromJson(Map<String, dynamic> json) => _$CourseFromJson(json);
Map<String, dynamic> toJson() => _$CourseToJson(this);
}

29
lib/models/course.g.dart Normal file
View File

@ -0,0 +1,29 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'course.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
Course _$CourseFromJson(Map<String, dynamic> json) => Course(
id: (json['id'] as num?)?.toInt(),
level: json['level'] as String?,
title: json['title'] as String?,
isActive: json['is_active'] as bool?,
courseId: (json['course_id'] as num?)?.toInt(),
thumbnail: json['thumbnail'] as String?,
description: json['description'] as String?,
displayOrder: (json['display_order'] as num?)?.toInt(),
);
Map<String, dynamic> _$CourseToJson(Course instance) => <String, dynamic>{
'id': instance.id,
'level': instance.level,
'title': instance.title,
'thumbnail': instance.thumbnail,
'description': instance.description,
'course_id': instance.courseId,
'is_active': instance.isActive,
'display_order': instance.displayOrder,
};

View File

@ -0,0 +1,20 @@
import 'package:json_annotation/json_annotation.dart';
part 'course_category.g.dart';
@JsonSerializable()
class CourseCategory {
final int? id;
final String? name;
@JsonKey(name: 'is_active')
final bool? isActive;
const CourseCategory({this.id, this.name, this.isActive});
factory CourseCategory.fromJson(Map<String, dynamic> json) =>
_$CourseCategoryFromJson(json);
Map<String, dynamic> toJson() => _$CourseCategoryToJson(this);
}

View File

@ -0,0 +1,21 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'course_category.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CourseCategory _$CourseCategoryFromJson(Map<String, dynamic> json) =>
CourseCategory(
id: (json['id'] as num?)?.toInt(),
name: json['name'] as String?,
isActive: json['is_active'] as bool?,
);
Map<String, dynamic> _$CourseCategoryToJson(CourseCategory instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'is_active': instance.isActive,
};

View File

@ -0,0 +1,19 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:yimaru_app/models/course_progress.dart';
import 'package:yimaru_app/models/course.dart';
part 'course_detail.g.dart';
@JsonSerializable()
class CourseDetail {
final Course? course;
final CourseProgress? courseProgress;
const CourseDetail({this.course, this.courseProgress});
factory CourseDetail.fromJson(Map<String, dynamic> json) =>
_$CourseDetailFromJson(json);
Map<String, dynamic> toJson() => _$CourseDetailToJson(this);
}

View File

@ -0,0 +1,23 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'course_detail.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CourseDetail _$CourseDetailFromJson(Map<String, dynamic> json) => CourseDetail(
course: json['course'] == null
? null
: Course.fromJson(json['course'] as Map<String, dynamic>),
courseProgress: json['courseProgress'] == null
? null
: CourseProgress.fromJson(
json['courseProgress'] as Map<String, dynamic>),
);
Map<String, dynamic> _$CourseDetailToJson(CourseDetail instance) =>
<String, dynamic>{
'course': instance.course,
'courseProgress': instance.courseProgress,
};

View File

@ -0,0 +1,57 @@
import 'package:json_annotation/json_annotation.dart';
part 'course_lesson.g.dart';
@JsonSerializable()
class CourseLesson {
int? id;
String? title;
int? duration;
String? status;
String? thumbnail;
String? resolution;
String? visibility;
String? description;
@JsonKey(name: 'video_url')
String? videoUrl;
@JsonKey(name: 'instructor_id')
int? instructorId;
@JsonKey(name: 'sub_course_id')
int? courseId;
@JsonKey(name: 'vimeo_status')
String? vimeoStatus;
@JsonKey(name: 'display_order')
int? displayOrder;
CourseLesson(
{this.id,
this.title,
this.status,
this.courseId,
this.videoUrl,
this.duration,
this.thumbnail,
this.visibility,
this.resolution,
this.vimeoStatus,
this.description,
this.displayOrder,
this.instructorId});
factory CourseLesson.fromJson(Map<String, dynamic> json) =>
_$CourseLessonFromJson(json);
Map<String, dynamic> toJson() => _$CourseLessonToJson(this);
}

View File

@ -0,0 +1,40 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'course_lesson.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CourseLesson _$CourseLessonFromJson(Map<String, dynamic> json) => CourseLesson(
id: (json['id'] as num?)?.toInt(),
title: json['title'] as String?,
status: json['status'] as String?,
courseId: (json['sub_course_id'] as num?)?.toInt(),
videoUrl: json['video_url'] as String?,
duration: (json['duration'] as num?)?.toInt(),
thumbnail: json['thumbnail'] as String?,
visibility: json['visibility'] as String?,
resolution: json['resolution'] as String?,
vimeoStatus: json['vimeo_status'] as String?,
description: json['description'] as String?,
displayOrder: (json['display_order'] as num?)?.toInt(),
instructorId: (json['instructor_id'] as num?)?.toInt(),
);
Map<String, dynamic> _$CourseLessonToJson(CourseLesson instance) =>
<String, dynamic>{
'id': instance.id,
'title': instance.title,
'duration': instance.duration,
'status': instance.status,
'thumbnail': instance.thumbnail,
'resolution': instance.resolution,
'visibility': instance.visibility,
'description': instance.description,
'video_url': instance.videoUrl,
'instructor_id': instance.instructorId,
'sub_course_id': instance.courseId,
'vimeo_status': instance.vimeoStatus,
'display_order': instance.displayOrder,
};

View File

@ -0,0 +1,42 @@
import 'package:json_annotation/json_annotation.dart';
part 'course_progress.g.dart';
@JsonSerializable()
class CourseProgress {
final String? level;
final String? title;
final String? description;
@JsonKey(name: 'is_locked')
final bool? isLocked;
@JsonKey(name: 'sub_course_id')
final int? courseId;
@JsonKey(name: 'display_order')
final int? displayOrder;
@JsonKey(name: 'progress_status')
final String? progressStatus;
@JsonKey(name: 'progress_percentage')
final double? progressPercentage;
const CourseProgress(
{this.level,
this.title,
this.isLocked,
this.courseId,
this.description,
this.displayOrder,
this.progressStatus,
this.progressPercentage});
factory CourseProgress.fromJson(Map<String, dynamic> json) =>
_$CourseProgressFromJson(json);
Map<String, dynamic> toJson() => _$CourseProgressToJson(this);
}

View File

@ -0,0 +1,31 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'course_progress.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CourseProgress _$CourseProgressFromJson(Map<String, dynamic> json) =>
CourseProgress(
level: json['level'] as String?,
title: json['title'] as String?,
isLocked: json['is_locked'] as bool?,
courseId: (json['sub_course_id'] as num?)?.toInt(),
description: json['description'] as String?,
displayOrder: (json['display_order'] as num?)?.toInt(),
progressStatus: json['progress_status'] as String?,
progressPercentage: (json['progress_percentage'] as num?)?.toDouble(),
);
Map<String, dynamic> _$CourseProgressToJson(CourseProgress instance) =>
<String, dynamic>{
'level': instance.level,
'title': instance.title,
'description': instance.description,
'is_locked': instance.isLocked,
'sub_course_id': instance.courseId,
'display_order': instance.displayOrder,
'progress_status': instance.progressStatus,
'progress_percentage': instance.progressPercentage,
};

View File

@ -0,0 +1,33 @@
import 'package:json_annotation/json_annotation.dart';
part 'course_subcategory.g.dart';
@JsonSerializable()
class CourseSubcategory {
final int? id;
final String? title;
final String? thumbnail;
final String? description;
@JsonKey(name: 'is_active')
final bool? isActive;
@JsonKey(name: 'category_id')
final int? categoryId;
const CourseSubcategory(
{this.id,
this.title,
this.isActive,
this.thumbnail,
this.categoryId,
this.description});
factory CourseSubcategory.fromJson(Map<String, dynamic> json) =>
_$CourseSubcategoryFromJson(json);
Map<String, dynamic> toJson() => _$CourseSubcategoryToJson(this);
}

View File

@ -0,0 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'course_subcategory.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CourseSubcategory _$CourseSubcategoryFromJson(Map<String, dynamic> json) =>
CourseSubcategory(
id: (json['id'] as num?)?.toInt(),
title: json['title'] as String?,
isActive: json['is_active'] as bool?,
thumbnail: json['thumbnail'] as String?,
categoryId: (json['category_id'] as num?)?.toInt(),
description: json['description'] as String?,
);
Map<String, dynamic> _$CourseSubcategoryToJson(CourseSubcategory instance) =>
<String, dynamic>{
'id': instance.id,
'title': instance.title,
'thumbnail': instance.thumbnail,
'description': instance.description,
'is_active': instance.isActive,
'category_id': instance.categoryId,
};

43
lib/models/practice.dart Normal file
View File

@ -0,0 +1,43 @@
import 'package:json_annotation/json_annotation.dart';
part 'practice.g.dart';
@JsonSerializable()
class Practice {
final int? id;
final String? title;
final String? status;
final String? persona;
final String? description;
@JsonKey(name: 'owner_id')
final int? ownerId;
@JsonKey(name: 'set_type')
final String? setType;
@JsonKey(name: 'owner_type')
final String? ownerType;
@JsonKey(name: 'shuffle_questions')
final bool? shuffleQuestions;
const Practice(
{this.id,
this.title,
this.status,
this.setType,
this.persona,
this.ownerId,
this.ownerType,
this.description,
this.shuffleQuestions});
factory Practice.fromJson(Map<String, dynamic> json) =>
_$PracticeFromJson(json);
Map<String, dynamic> toJson() => _$PracticeToJson(this);
}

View File

@ -0,0 +1,31 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'practice.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
Practice _$PracticeFromJson(Map<String, dynamic> json) => Practice(
id: (json['id'] as num?)?.toInt(),
title: json['title'] as String?,
status: json['status'] as String?,
setType: json['set_type'] as String?,
persona: json['persona'] as String?,
ownerId: (json['owner_id'] as num?)?.toInt(),
ownerType: json['owner_type'] as String?,
description: json['description'] as String?,
shuffleQuestions: json['shuffle_questions'] as bool?,
);
Map<String, dynamic> _$PracticeToJson(Practice instance) => <String, dynamic>{
'id': instance.id,
'title': instance.title,
'status': instance.status,
'persona': instance.persona,
'description': instance.description,
'owner_id': instance.ownerId,
'set_type': instance.setType,
'owner_type': instance.ownerType,
'shuffle_questions': instance.shuffleQuestions,
};

View File

@ -0,0 +1,46 @@
import 'package:json_annotation/json_annotation.dart';
part 'practice_question.g.dart';
@JsonSerializable()
class PracticeQuestion {
final int? id;
final int? points;
final String? tips;
@JsonKey(name: 'set_id')
final int? setId;
@JsonKey(name: 'question_id')
final int? questionId;
@JsonKey(name: 'display_order')
final int? displayOrder;
@JsonKey(name: 'question_text')
final String? questionText;
@JsonKey(name: 'question_type')
final String? questionType;
@JsonKey(name: 'question_status')
final String? questionStatus;
const PracticeQuestion(
{this.id,
this.tips,
this.setId,
this.points,
this.questionId,
this.questionText,
this.questionType,
this.displayOrder,
this.questionStatus});
factory PracticeQuestion.fromJson(Map<String, dynamic> json) =>
_$PracticeQuestionFromJson(json);
Map<String, dynamic> toJson() => _$PracticeQuestionToJson(this);
}

View File

@ -0,0 +1,33 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'practice_question.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
PracticeQuestion _$PracticeQuestionFromJson(Map<String, dynamic> json) =>
PracticeQuestion(
id: (json['id'] as num?)?.toInt(),
tips: json['tips'] as String?,
setId: (json['set_id'] as num?)?.toInt(),
points: (json['points'] as num?)?.toInt(),
questionId: (json['question_id'] as num?)?.toInt(),
questionText: json['question_text'] as String?,
questionType: json['question_type'] as String?,
displayOrder: (json['display_order'] as num?)?.toInt(),
questionStatus: json['question_status'] as String?,
);
Map<String, dynamic> _$PracticeQuestionToJson(PracticeQuestion instance) =>
<String, dynamic>{
'id': instance.id,
'points': instance.points,
'tips': instance.tips,
'set_id': instance.setId,
'question_id': instance.questionId,
'display_order': instance.displayOrder,
'question_text': instance.questionText,
'question_type': instance.questionType,
'question_status': instance.questionStatus,
};

View File

@ -12,12 +12,10 @@ class UserModel {
final String? country; final String? country;
final String? occupation; final String? occupation;
final bool? userInfoLoaded; final bool? userInfoLoaded;
@JsonKey(name: 'user_id') @JsonKey(name: 'user_id')
final int? userId; final int? userId;
@ -55,10 +53,41 @@ class UserModel {
this.accessToken, this.accessToken,
this.refreshToken, this.refreshToken,
this.profilePicture, this.profilePicture,
this.userInfoLoaded , this.userInfoLoaded,
this.profileCompleted, this.profileCompleted,
}); });
UserModel copyWith(
{int? userId,
String? email,
String? gender,
String? region,
String? country,
String? lastName,
String? birthday,
String? firstName,
String? occupation,
String? accessToken,
String? refreshToken,
bool? userInfoLoaded,
bool? profileCompleted,
String? profilePicture}) =>
UserModel(
email: email ?? this.email,
userId: userId ?? this.userId,
gender: gender ?? this.gender,
region: region ?? this.region,
country: country ?? this.country,
lastName: lastName ?? this.lastName,
birthday: birthday ?? this.birthday,
firstName: firstName ?? this.firstName,
occupation: occupation ?? this.occupation,
accessToken: accessToken ?? this.accessToken,
refreshToken: refreshToken ?? this.refreshToken,
userInfoLoaded: userInfoLoaded ?? this.userInfoLoaded,
profilePicture: profilePicture ?? this.profilePicture,
profileCompleted: profileCompleted ?? this.profileCompleted);
factory UserModel.fromJson(Map<String, dynamic> json) => factory UserModel.fromJson(Map<String, dynamic> json) =>
_$UserModelFromJson(json); _$UserModelFromJson(json);

View File

@ -19,8 +19,8 @@ UserModel _$UserModelFromJson(Map<String, dynamic> json) => UserModel(
accessToken: json['access_token'] as String?, accessToken: json['access_token'] as String?,
refreshToken: json['refresh_token'] as String?, refreshToken: json['refresh_token'] as String?,
profilePicture: json['profile_picture_url'] as String?, profilePicture: json['profile_picture_url'] as String?,
userInfoLoaded: json['userInfoLoaded'] as bool?,
profileCompleted: json['profile_completed'] as bool?, profileCompleted: json['profile_completed'] as bool?,
userInfoLoaded: json['userInfoLoaded'] as bool? ?? false,
); );
Map<String, dynamic> _$UserModelToJson(UserModel instance) => <String, dynamic>{ Map<String, dynamic> _$UserModelToJson(UserModel instance) => <String, dynamic>{

View File

@ -1,5 +1,12 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:yimaru_app/models/assessment.dart'; import 'package:yimaru_app/models/assessment.dart';
import 'package:yimaru_app/models/course_subcategory.dart';
import 'package:yimaru_app/models/course_category.dart';
import 'package:yimaru_app/models/course_lesson.dart';
import 'package:yimaru_app/models/course_progress.dart';
import 'package:yimaru_app/models/course.dart';
import 'package:yimaru_app/models/practice.dart';
import 'package:yimaru_app/models/practice_question.dart';
import 'package:yimaru_app/models/user_model.dart'; import 'package:yimaru_app/models/user_model.dart';
import 'package:yimaru_app/services/dio_service.dart'; import 'package:yimaru_app/services/dio_service.dart';
import 'package:yimaru_app/ui/common/app_constants.dart'; import 'package:yimaru_app/ui/common/app_constants.dart';
@ -8,13 +15,15 @@ import '../app/app.locator.dart';
import '../ui/common/enmus.dart'; import '../ui/common/enmus.dart';
class ApiService { class ApiService {
// Dependency injection
final _service = locator<DioService>(); final _service = locator<DioService>();
// Register // Register
Future<Map<String, dynamic>> registerWithEmail(Map<String, dynamic> data) async { Future<Map<String, dynamic>> registerWithEmail(
Map<String, dynamic> data) async {
try { try {
Response response = await _service.dio.post( Response response = await _service.dio.post(
'$kBaseUrl/$kUserUrl/$kRegisterUrl', '$kBaseUrl/$kUserBaseUrl/$kRegisterUrl',
data: data, data: data,
); );
@ -37,8 +46,8 @@ class ApiService {
} }
} }
// Email Login // Email login
Future<Map<String, dynamic>> emailLogin(Map<String, dynamic> data) async { Future<Map<String, dynamic>> login(Map<String, dynamic> data) async {
try { try {
Response response = await _service.dio.post( Response response = await _service.dio.post(
'$kBaseUrl/$kLoginUrl', '$kBaseUrl/$kLoginUrl',
@ -97,7 +106,7 @@ class ApiService {
Future<Map<String, dynamic>> verifyOtp(Map<String, dynamic> data) async { Future<Map<String, dynamic>> verifyOtp(Map<String, dynamic> data) async {
try { try {
Response response = await _service.dio.post( Response response = await _service.dio.post(
'$kBaseUrl/$kUserUrl/$kVerifyOtpUrl', '$kBaseUrl/$kUserBaseUrl/$kVerifyOtpUrl',
data: data, data: data,
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
@ -124,7 +133,7 @@ class ApiService {
Future<Map<String, dynamic>> resendOtp(Map<String, dynamic> data) async { Future<Map<String, dynamic>> resendOtp(Map<String, dynamic> data) async {
try { try {
Response response = await _service.dio.post( Response response = await _service.dio.post(
'$kBaseUrl/$kUserUrl/$kResendOtpUrl', '$kBaseUrl/$kUserBaseUrl/$kResendOtpUrl',
data: data, data: data,
); );
@ -152,7 +161,7 @@ class ApiService {
Map<String, dynamic> data) async { Map<String, dynamic> data) async {
try { try {
Response response = await _service.dio.post( Response response = await _service.dio.post(
'$kBaseUrl/$kUserUrl/$kRequestResetCode', '$kBaseUrl/$kUserBaseUrl/$kRequestResetCode',
data: data, data: data,
); );
@ -179,7 +188,7 @@ class ApiService {
Future<Map<String, dynamic>> resetPassword(Map<String, dynamic> data) async { Future<Map<String, dynamic>> resetPassword(Map<String, dynamic> data) async {
try { try {
Response response = await _service.dio.post( Response response = await _service.dio.post(
'$kBaseUrl/$kUserUrl/$kResetPassword', '$kBaseUrl/$kUserBaseUrl/$kResetPassword',
data: data, data: data,
); );
@ -206,7 +215,7 @@ class ApiService {
Future<Map<String, dynamic>> getProfileStatus(UserModel? user) async { Future<Map<String, dynamic>> getProfileStatus(UserModel? user) async {
try { try {
Response response = await _service.dio.get( Response response = await _service.dio.get(
'$kBaseUrl/$kUserUrl/${user?.userId}/$kProfileStatusUrl', '$kBaseUrl/$kUserBaseUrl/${user?.userId}/$kProfileStatusUrl',
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
@ -233,7 +242,7 @@ class ApiService {
Future<Map<String, dynamic>> getProfileData(int? userId) async { Future<Map<String, dynamic>> getProfileData(int? userId) async {
try { try {
Response response = await _service.dio.get( Response response = await _service.dio.get(
'$kBaseUrl/$kUserUrl/$kGetUserUrl/$userId', '$kBaseUrl/$kUserBaseUrl/$kGetUserUrl',
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
@ -261,7 +270,7 @@ class ApiService {
Map<String, dynamic> data) async { Map<String, dynamic> data) async {
try { try {
Response response = await _service.dio.put( Response response = await _service.dio.put(
'$kBaseUrl/$kUserUrl', '$kBaseUrl/$kUserBaseUrl',
data: data, data: data,
); );
@ -291,7 +300,7 @@ class ApiService {
late FormData formData; late FormData formData;
if (data['profile_picture_url'] if (data['profile_picture_url']
.toString() .toString()
.contains('com.example.yimaru_app/')) { .contains('com.yimaru.lms.app/')) {
formData = FormData.fromMap({ formData = FormData.fromMap({
'file': data['profile_picture_url'].toString().isNotEmpty 'file': data['profile_picture_url'].toString().isNotEmpty
? MultipartFile.fromFileSync( ? MultipartFile.fromFileSync(
@ -313,7 +322,7 @@ class ApiService {
}); });
} }
Response response = await _service.dio.post( Response response = await _service.dio.post(
'$kBaseUrl/$kUserUrl/$userId/$kUpdateProfileImage', '$kBaseUrl/$kUserBaseUrl/$userId/$kUpdateProfileImage',
data: formData, data: formData,
); );
@ -359,4 +368,195 @@ class ApiService {
return []; return [];
} }
} }
// Course categories
Future<List<CourseCategory>> getCourseCategories() async {
try {
List<CourseCategory> categories = [];
final Response response = await _service.dio
.get('$kBaseUrl/$kCourseBaseUrl/$kCourseCategoryUrl');
if (response.statusCode == 200) {
var data = response.data;
var decodedData = data['data']['categories'] as List;
categories = decodedData.map(
(e) {
return CourseCategory.fromJson(e);
},
).toList();
return categories;
}
return [];
} catch (e) {
return [];
}
}
// Course subcategory
Future<List<CourseSubcategory>> getCourseSubcategories(int id) async {
try {
List<CourseSubcategory> subcategories = [];
final Response response = await _service.dio.get(
'$kBaseUrl/$kCourseBaseUrl/$kCourseCategoryUrl/$id/$kCoursesUrl');
if (response.statusCode == 200) {
var data = response.data;
var decodedData = data['data']['courses'] as List;
subcategories = decodedData.map(
(e) {
return CourseSubcategory.fromJson(e);
},
).toList();
return subcategories;
}
return [];
} catch (e) {
return [];
}
}
// Sub-courses
Future<List<Course>> getCourses(int id) async {
try {
List<Course> courses = [];
final Response response = await _service.dio
.get('$kBaseUrl/$kCourseBaseUrl/$kCoursesUrl/$id/$kSubcoursesUrl');
if (response.statusCode == 200) {
var data = response.data;
var decodedData = data['data']['sub_courses'] as List;
courses = decodedData.map(
(e) {
return Course.fromJson(e);
},
).toList();
return courses;
}
return [];
} catch (e) {
return [];
}
}
// Course progress
Future<List<CourseProgress>> getCourseProgress(int id) async {
try {
List<CourseProgress> courseProgress = [];
final Response response =
await _service.dio.get('$kBaseUrl/$kCourseProgressUrl/$id');
if (response.statusCode == 200) {
var data = response.data;
var decodedData = data['data'] as List;
courseProgress = decodedData.map(
(e) {
return CourseProgress.fromJson(e);
},
).toList();
return courseProgress;
}
return [];
} catch (e) {
return [];
}
}
// Course videos
Future<List<CourseLesson>> getCourseLessons(int id) async {
try {
List<CourseLesson> courseLessons = [];
final Response response = await _service.dio.get(
'$kBaseUrl/$kCourseBaseUrl/$kSubcoursesUrl/$id/$kPublishedVideos');
if (response.statusCode == 200) {
var data = response.data;
var decodedData = data['data'] as List;
courseLessons = decodedData.map(
(e) {
return CourseLesson.fromJson(e);
},
).toList();
return courseLessons;
}
return [];
} catch (e) {
return [];
}
}
// Complete lesson
Future<Map<String, dynamic>> completeLesson(int id) async {
try {
Response response = await _service.dio.post(
'$kBaseUrl/$kLessonProgressUrl/$id/$kCompleteLessonUrl',
);
if (response.statusCode == 200) {
return {'status': ResponseStatus.success, 'message': 'Video completed'};
} else {
return {
'status': ResponseStatus.failure,
'message': 'Unknown Error Occurred'
};
}
} on DioException catch (e) {
return {
'status': ResponseStatus.failure,
'message': e.response?.data.toString(),
};
}
}
// Course practices
Future<List<Practice>> getCoursePractices(Map<String, dynamic> data) async {
try {
List<Practice> coursePractices = [];
final Response response = await _service.dio
.get('$kBaseUrl/$kPracticeBaseUrl/$kCoursePractice', data: data);
if (response.statusCode == 200) {
var data = response.data;
var decodedData = data['data'] as List;
coursePractices = decodedData.map(
(e) {
return Practice.fromJson(e);
},
).toList();
return coursePractices;
}
return [];
} catch (e) {
return [];
}
}
// Course practic questions
Future<List<PracticeQuestion>> getCoursePracticeQuestions(int id) async {
try {
List<PracticeQuestion> coursePracticeQuestions = [];
final Response response = await _service.dio
.get('$kBaseUrl/$kPracticeBaseUrl/$id/$kCoursePracticeQuestions');
if (response.statusCode == 200) {
var data = response.data;
var decodedData = data['data'] as List;
coursePracticeQuestions = decodedData.map(
(e) {
return PracticeQuestion.fromJson(e);
},
).toList();
return coursePracticeQuestions;
}
return [];
} catch (e) {
return [];
}
}
} }

View File

@ -0,0 +1,41 @@
import 'package:audioplayers/audioplayers.dart';
import 'package:stacked/stacked.dart';
import '../ui/common/helper_functions.dart';
class AudioPlayerService with ListenableServiceMixin {
final AudioPlayer _player = AudioPlayer();
AudioPlayer get player => _player;
AudioPlayerService() {
_player.setReleaseMode(ReleaseMode.stop);
}
// Streams
Stream<Duration> get positionStream => _player.onPositionChanged;
Stream<Duration> get durationStream => _player.onDurationChanged;
// Optional: player state
Stream<PlayerState> get stateStream => _player.onPlayerStateChanged;
Future<void> playUrl(String url) async {
final playableUrl = getPlayableUrl(url);
if (playableUrl == null) {
throw Exception("Invalid audio URL");
}
await _player.play(UrlSource(playableUrl));
}
Future<void> playLocal(String url) async {
await _player.play(UrlSource(url));
}
Future<void> pause() async => await _player.pause();
Future<void> seek(Duration position) async => await _player.seek(position);
}

View File

@ -4,16 +4,20 @@ import 'package:yimaru_app/models/user_model.dart';
import 'package:yimaru_app/services/secure_storage_service.dart'; import 'package:yimaru_app/services/secure_storage_service.dart';
class AuthenticationService with ListenableServiceMixin { class AuthenticationService with ListenableServiceMixin {
// Dependency injection
final _secureService = locator<SecureStorageService>(); final _secureService = locator<SecureStorageService>();
AuthenticationService() { // User data
listenToReactiveValues([_user]);
}
UserModel? _user; UserModel? _user;
UserModel? get user => _user; UserModel? get user => _user;
// Initialization
AuthenticationService() {
listenToReactiveValues([_user]);
}
// Check user logged in
Future<bool> userLoggedIn() async { Future<bool> userLoggedIn() async {
if (await _secureService.getString('userId') != null) { if (await _secureService.getString('userId') != null) {
return true; return true;
@ -21,14 +25,18 @@ class AuthenticationService with ListenableServiceMixin {
return false; return false;
} }
// Get access token
Future<String?> getAccessToken() async => Future<String?> getAccessToken() async =>
await _secureService.getString('accessToken'); await _secureService.getString('accessToken');
// Get refresh token
Future<String?> getRefreshToken() async => Future<String?> getRefreshToken() async =>
await _secureService.getString('refreshToken'); await _secureService.getString('refreshToken');
// Get user id
Future<int?> getUserId() async => await _secureService.getInt('userId'); Future<int?> getUserId() async => await _secureService.getInt('userId');
// Save tokens
Future<void> saveTokens({ Future<void> saveTokens({
required String access, required String access,
required String refresh, required String refresh,
@ -37,7 +45,7 @@ class AuthenticationService with ListenableServiceMixin {
await _secureService.setString('refreshToken', refresh); await _secureService.setString('refreshToken', refresh);
} }
// Save user credential
Future<void> saveUserCredential(Map<String, dynamic> data) async { Future<void> saveUserCredential(Map<String, dynamic> data) async {
await _secureService.setInt('userId', data['userId']); await _secureService.setInt('userId', data['userId']);
await _secureService.setString('accessToken', data['accessToken']); await _secureService.setString('accessToken', data['accessToken']);
@ -50,10 +58,16 @@ class AuthenticationService with ListenableServiceMixin {
); );
} }
// Save profile status
Future<void> saveProfileStatus(bool value) async { Future<void> saveProfileStatus(bool value) async {
await _secureService.setBool('profileCompleted', value); await _secureService.setBool('profileCompleted', value);
_user = UserModel( _user = _user?.copyWith(
userInfoLoaded: _user?.userInfoLoaded ?? false,
profileCompleted: await _secureService.getBool('profileCompleted'),
);
/* UserModel(
email: _user?.email, email: _user?.email,
gender: _user?.gender, gender: _user?.gender,
region: _user?.region, region: _user?.region,
@ -68,12 +82,18 @@ class AuthenticationService with ListenableServiceMixin {
profilePicture: _user?.profilePicture, profilePicture: _user?.profilePicture,
userInfoLoaded: _user?.userInfoLoaded ?? false, userInfoLoaded: _user?.userInfoLoaded ?? false,
profileCompleted: await _secureService.getBool('profileCompleted')); profileCompleted: await _secureService.getBool('profileCompleted'));
*/
notifyListeners(); notifyListeners();
} }
Future<void> saveProfileImage(String image) async { Future<void> saveProfilePicture(String image) async {
await _secureService.setString('profileImage', image); await _secureService.setString('profilePicture', image);
_user = UserModel( _user = _user?.copyWith(
userInfoLoaded: _user?.userInfoLoaded ?? false,
profilePicture: await _secureService.getString('profilePicture'),
);
/*UserModel(
email: _user?.email, email: _user?.email,
gender: _user?.gender, gender: _user?.gender,
region: _user?.region, region: _user?.region,
@ -87,18 +107,16 @@ class AuthenticationService with ListenableServiceMixin {
refreshToken: _user?.refreshToken, refreshToken: _user?.refreshToken,
profileCompleted: _user?.profileCompleted, profileCompleted: _user?.profileCompleted,
userInfoLoaded: _user?.userInfoLoaded ?? false, userInfoLoaded: _user?.userInfoLoaded ?? false,
profilePicture: await _secureService.getString('profileImage'), profilePicture: await _secureService.getString('profilePicture'),
); );
*/
notifyListeners(); notifyListeners();
} }
Future<void> saveUserData( Future<void> saveUserData(UserModel data) async {
{required String image, required UserModel data}) async {
await _secureService.setBool('userInfoLoaded', true); await _secureService.setBool('userInfoLoaded', true);
await _secureService.setBool( await _secureService.setBool(
'profileCompleted', data.profileCompleted ?? false); 'profileCompleted', data.profileCompleted ?? false);
await _secureService.setString('profilePicture', image);
await _secureService.setString('email', data.email ?? ''); await _secureService.setString('email', data.email ?? '');
await _secureService.setString('region', data.region ?? ''); await _secureService.setString('region', data.region ?? '');
await _secureService.setString('gender', data.gender ?? ''); await _secureService.setString('gender', data.gender ?? '');
@ -113,7 +131,6 @@ class AuthenticationService with ListenableServiceMixin {
gender: data.gender, gender: data.gender,
region: data.region, region: data.region,
userInfoLoaded: true, userInfoLoaded: true,
profilePicture: image,
userId: _user?.userId, userId: _user?.userId,
country: data.country, country: data.country,
lastName: data.lastName, lastName: data.lastName,
@ -137,7 +154,17 @@ class AuthenticationService with ListenableServiceMixin {
await _secureService.setString('firstName', data['first_name']); await _secureService.setString('firstName', data['first_name']);
await _secureService.setString('occupation', data['occupation']); await _secureService.setString('occupation', data['occupation']);
_user = UserModel( _user = _user?.copyWith(
region: await _secureService.getString('region'),
gender: await _secureService.getString('gender'),
country: await _secureService.getString('country'),
lastName: await _secureService.getString('lastName'),
birthday: await _secureService.getString('birthday'),
firstName: await _secureService.getString('firstName'),
occupation: await _secureService.getString('occupation'),
);
/*UserModel(
email: _user?.email, email: _user?.email,
userId: _user?.userId, userId: _user?.userId,
accessToken: _user?.accessToken, accessToken: _user?.accessToken,
@ -151,7 +178,7 @@ class AuthenticationService with ListenableServiceMixin {
birthday: await _secureService.getString('birthday'), birthday: await _secureService.getString('birthday'),
firstName: await _secureService.getString('firstName'), firstName: await _secureService.getString('firstName'),
occupation: await _secureService.getString('occupation'), occupation: await _secureService.getString('occupation'),
); );*/
notifyListeners(); notifyListeners();
} }
@ -175,14 +202,14 @@ class AuthenticationService with ListenableServiceMixin {
occupation: await _secureService.getString('occupation'), occupation: await _secureService.getString('occupation'),
accessToken: await _secureService.getString('accessToken'), accessToken: await _secureService.getString('accessToken'),
refreshToken: await _secureService.getString('refreshToken'), refreshToken: await _secureService.getString('refreshToken'),
profilePicture: await _secureService.getString('profileImage'),
userInfoLoaded: await _secureService.getBool('userInfoLoaded'), userInfoLoaded: await _secureService.getBool('userInfoLoaded'),
profilePicture: await _secureService.getString('profilePicture'),
profileCompleted: await _secureService.getBool('profileCompleted'), profileCompleted: await _secureService.getBool('profileCompleted'),
); );
return _user; return _user;
} }
Future<void> logOut() async { Future<void> logout() async {
bool firstTimeInstall = await isFirstTimeInstall(); bool firstTimeInstall = await isFirstTimeInstall();
_user = null; _user = null;
await _secureService.clear(); await _secureService.clear();

View File

@ -0,0 +1,25 @@
import 'package:yimaru_app/app/app.locator.dart';
import 'package:yimaru_app/models/course_progress.dart';
import 'package:yimaru_app/services/api_service.dart';
import '../models/course_detail.dart';
class CourseService {
final _apiService = locator<ApiService>();
Future<List<CourseDetail>> getCoursesDetail(int id) async {
final courses = await _apiService.getCourses(id);
final progress = await _apiService.getCourseProgress(id);
final progressMap = {
for (var p in progress.whereType<CourseProgress>()) p.courseId: p
};
return courses.map((course) {
return CourseDetail(
course: course,
courseProgress: progressMap[course.id],
);
}).toList();
}
}

View File

@ -9,15 +9,21 @@ import '../app/app.locator.dart';
import '../ui/common/app_constants.dart'; import '../ui/common/app_constants.dart';
class DioService { class DioService {
// Dependency injection
final _navigationService = locator<NavigationService>(); final _navigationService = locator<NavigationService>();
final _authenticationService = locator<AuthenticationService>(); final _authenticationService = locator<AuthenticationService>();
// Initialization
final Dio _dio = Dio(); final Dio _dio = Dio();
Dio get dio => _dio;
final Dio _refreshDio = Dio(); // separate instance final Dio _refreshDio = Dio(); // separate instance
bool _isRefreshing = false; bool _isRefreshing = false;
final List<void Function()> _retryQueue = []; final List<void Function()> _retryQueue = [];
// Initialization
DioService() { DioService() {
_dio.options _dio.options
..baseUrl = kBaseUrl ..baseUrl = kBaseUrl
@ -33,6 +39,7 @@ class DioService {
); );
} }
// Response logger
void _onResponse( void _onResponse(
Response response, Response response,
ResponseInterceptorHandler handler, ResponseInterceptorHandler handler,
@ -69,6 +76,7 @@ class DioService {
handler.next(options); handler.next(options);
} }
// Error logger
Future<void> _onError( Future<void> _onError(
DioException error, DioException error,
ErrorInterceptorHandler handler, ErrorInterceptorHandler handler,
@ -125,6 +133,7 @@ class DioService {
} }
} }
// Refresh token
Future<bool> _refreshToken() async { Future<bool> _refreshToken() async {
final UserModel? user = await _authenticationService.getUser(); final UserModel? user = await _authenticationService.getUser();
@ -149,15 +158,14 @@ class DioService {
return true; return true;
} catch (e) { } catch (e) {
await _authenticationService.logOut(); await _authenticationService.logout();
await _navigationService.replaceWithLoginView(); await _navigationService.replaceWithLoginView();
return false; return false;
} }
} }
// Check request if immediately after token refreshed
bool _isRefreshRequest(RequestOptions options) { bool _isRefreshRequest(RequestOptions options) {
return options.path.contains(kRefreshTokenUrl); return options.path.contains(kRefreshTokenUrl);
} }
Dio get dio => _dio;
} }

View File

@ -1,21 +1,39 @@
import 'package:google_sign_in/google_sign_in.dart'; import 'package:google_sign_in/google_sign_in.dart';
import 'package:stacked/stacked.dart';
import 'package:yimaru_app/ui/common/app_constants.dart'; import 'package:yimaru_app/ui/common/app_constants.dart';
class GoogleAuthService { class GoogleAuthService with ListenableServiceMixin {
final GoogleSignIn signIn = GoogleSignIn.instance; // Initialization
final GoogleSignIn _signIn = GoogleSignIn.instance;
Future<GoogleSignInAccount?> googleAuth() async { GoogleSignInAccount? _googleUser;
GoogleSignInAccount? get googleUser => _googleUser;
// Initialization
GoogleAuthService() {
listenToReactiveValues([_googleUser]);
}
// Google logout
Future<void> logout() async {
await _signIn.signOut();
_googleUser = null;
notifyListeners();
}
// Google authentication
Future<void> googleAuth() async {
try { try {
GoogleSignInAccount? googleUser; await _signIn.initialize(serverClientId: kServerClientId).then((_) async {
await signIn.initialize(serverClientId: kServerClientId).then((_) async { _googleUser = await _signIn.attemptLightweightAuthentication();
googleUser = await signIn.attemptLightweightAuthentication();
googleUser ??= _googleUser ??=
await signIn.authenticate(scopeHint: ['email', 'profile']); await _signIn.authenticate(scopeHint: ['email', 'profile']);
}); });
return googleUser; notifyListeners();
} catch (e) { } catch (e) {
return null; rethrow;
} }
} }
} }

View File

@ -9,8 +9,10 @@ import '../ui/common/app_constants.dart';
import 'dio_service.dart'; import 'dio_service.dart';
class ImageDownloaderService { class ImageDownloaderService {
// Dependency injection
final _service = locator<DioService>(); final _service = locator<DioService>();
// Image downloader
Future<String> downloader(String? networkImage) async { Future<String> downloader(String? networkImage) async {
late File image; late File image;

View File

@ -6,10 +6,13 @@ import '../app/app.locator.dart';
import '../ui/common/ui_helpers.dart'; import '../ui/common/ui_helpers.dart';
class ImagePickerService { class ImagePickerService {
// Dependency injection
final _permissionHandler = locator<PermissionHandlerService>(); final _permissionHandler = locator<PermissionHandlerService>();
// Initialization
final ImagePicker _picker = ImagePicker(); final ImagePicker _picker = ImagePicker();
// Pick image from gallery
Future<String?> gallery() async { Future<String?> gallery() async {
try { try {
PermissionStatus status = PermissionStatus status =
@ -32,6 +35,7 @@ class ImagePickerService {
} }
} }
// Pick image from camera
Future<String?> camera() async { Future<String?> camera() async {
try { try {
PermissionStatus status = PermissionStatus status =

View File

@ -0,0 +1,138 @@
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:yimaru_app/app/app.locator.dart';
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await locator<NotificationService>().setupFlutterNotifications();
await locator<NotificationService>().showNotification(message);
}
class NotificationService {
final _messaging = FirebaseMessaging.instance;
bool _isFlutterLocalNotificationInitialized = false;
final _localNotifications = FlutterLocalNotificationsPlugin();
Future<void> initialize() async {
// Initialize FCM token
await updateFCMToken();
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
// Request permission
await _requestPermission();
// setup message handle
await _setupMessageHandler();
// Subscribe to all devices
subscribeToTopic('yimaru');
}
Future<void> _requestPermission() async {
await _messaging.requestPermission(
alert: true,
badge: true,
sound: true,
carPlay: false,
provisional: false,
announcement: false,
criticalAlert: false);
}
Future<void> setupFlutterNotifications() async {
if (_isFlutterLocalNotificationInitialized) {
return;
}
// Android setup
const channel = AndroidNotificationChannel(
'yimaru', // id
'Yimaru', // title
importance: Importance.high,
);
await _localNotifications
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(channel);
const initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
// IOS setup
const initializationSettingsDarwin = DarwinInitializationSettings();
const initializationSettings = InitializationSettings(
android: initializationSettingsAndroid,
iOS: initializationSettingsDarwin);
// Flutter notification setup
await _localNotifications.initialize(
settings: initializationSettings,
onDidReceiveNotificationResponse: (NotificationResponse response) {
if (response.payload == 'Page') {
// navigatorKey.currentState?.pushNamed('RouteName');
}
},
);
_isFlutterLocalNotificationInitialized = true;
}
Future<void> showNotification(RemoteMessage message) async {
RemoteNotification? notification = message.notification;
AndroidNotification? android = message.notification?.android;
if (notification != null && android != null) {
await _localNotifications.show(
id: notification.hashCode,
title: notification.title,
body: notification.body,
notificationDetails: const NotificationDetails(
android: AndroidNotificationDetails('yimaru', 'Yimaru',
enableVibration: true,
priority: Priority.high,
icon: '@mipmap/ic_launcher',
importance: Importance.high),
iOS: DarwinNotificationDetails(
presentAlert: true, presentBadge: true, presentSound: true)),
);
}
}
Future<void> _setupMessageHandler() async {
// Foreground message
FirebaseMessaging.onMessage
.listen((RemoteMessage message) => showNotification(message));
// Background message
FirebaseMessaging.onMessageOpenedApp.listen(_handleBackgroundMessage);
// Opened app
final initialMessage = await _messaging.getInitialMessage();
if (initialMessage != null) {
_handleBackgroundMessage(initialMessage);
}
}
void _handleBackgroundMessage(RemoteMessage message) {
if (message.data['type'] == 'Page') {
// navigatorKey.currentState?.pushNamed('RouteName');
}
}
Future<void> subscribeToTopic(String topic) async {
await FirebaseMessaging.instance.subscribeToTopic(topic);
}
Future<void> updateFCMToken() async {
// print('DEVICE TOKEN: ${await _messaging.getToken()}');
_messaging.onTokenRefresh.listen((newToken) {
// updateTokenOnServer(newToken);
});
}
}

View File

@ -3,6 +3,7 @@ import 'package:permission_handler/permission_handler.dart';
import '../ui/common/ui_helpers.dart'; import '../ui/common/ui_helpers.dart';
class PermissionHandlerService { class PermissionHandlerService {
// Check permission category
Future<PermissionStatus> requestPermission( Future<PermissionStatus> requestPermission(
Permission requestedPermission) async { Permission requestedPermission) async {
if (requestedPermission == Permission.camera) { if (requestedPermission == Permission.camera) {
@ -17,6 +18,7 @@ class PermissionHandlerService {
return PermissionStatus.denied; return PermissionStatus.denied;
} }
// Request permission
Future<PermissionStatus> request(Permission permission) async { Future<PermissionStatus> request(Permission permission) async {
if (await permission.isDenied) { if (await permission.isDenied) {
final PermissionStatus status = await permission.request(); final PermissionStatus status = await permission.request();

View File

@ -15,8 +15,7 @@ extension BoolParsing on String {
} }
class SecureStorageService { class SecureStorageService {
// Create storage // Initialization
late final FlutterSecureStorage _storage; late final FlutterSecureStorage _storage;
SecureStorageService() { SecureStorageService() {
@ -31,33 +30,40 @@ class SecureStorageService {
); );
} }
// Clear storage data
Future<void> clear() async { Future<void> clear() async {
_storage.deleteAll(); _storage.deleteAll();
} }
// Get boolean data from storage
Future<bool?> getBool(String key) async { Future<bool?> getBool(String key) async {
String? result = await _storage.read(key: key); String? result = await _storage.read(key: key);
return result?.parseBool(); return result?.parseBool();
} }
// Get string data from storage
Future<String?> getString(String key) async { Future<String?> getString(String key) async {
return await _storage.read(key: key); return await _storage.read(key: key);
} }
// Get integer data from storage
Future<int?> getInt(String key) async { Future<int?> getInt(String key) async {
return await _storage.read(key: key) == null return await _storage.read(key: key) == null
? null ? null
: int.parse(await _storage.read(key: key) ?? '0'); : int.parse(await _storage.read(key: key) ?? '0');
} }
// Save string data to storage
Future<void> setString(String key, String value) async { Future<void> setString(String key, String value) async {
await _storage.write(key: key, value: value); await _storage.write(key: key, value: value);
} }
// Save integer data to storage
Future<void> setInt(String key, int value) async { Future<void> setInt(String key, int value) async {
await _storage.write(key: key, value: value.toString()); await _storage.write(key: key, value: value.toString());
} }
// Save boolean data to storage
Future<void> setBool(String key, bool value) async { Future<void> setBool(String key, bool value) async {
await _storage.write(key: key, value: value.toString()); await _storage.write(key: key, value: value.toString());
} }

View File

@ -0,0 +1,26 @@
import 'package:pinput/pinput.dart';
import 'package:smart_auth/smart_auth.dart';
class SmartAuthService implements SmsRetriever {
final SmartAuth _smartAuth = SmartAuth.instance;
@override
Future<void> dispose() => _smartAuth.removeUserConsentApiListener();
@override
Future<String?> getSmsCode() async {
final res = await _smartAuth.getSmsWithUserConsentApi();
if (res.hasData) {
final code = res.requireData.code;
return code;
} else if (res.isCanceled) {
return null;
} else {
return null;
}
}
@override
bool get listenForMultipleSms => true;
}

View File

@ -8,34 +8,28 @@ import 'package:yimaru_app/services/secure_storage_service.dart';
import '../app/app.locator.dart'; import '../app/app.locator.dart';
class StatusCheckerService { class StatusCheckerService {
// Dependency injection
final storage = locator<SecureStorageService>(); final storage = locator<SecureStorageService>();
// Initialization
bool _previousConnection = true; bool _previousConnection = true;
bool get previousConnection => _previousConnection; bool get previousConnection => _previousConnection;
// Get phone battery level
Future<int> getBatteryLevel() async { Future<int> getBatteryLevel() async {
final battery = Battery(); final battery = Battery();
final batteryLevel = await battery.batteryLevel; final batteryLevel = await battery.batteryLevel;
return batteryLevel; return batteryLevel;
} }
Future<bool> userAuthenticated() async { // Check internet connection
await checkAndUpdate();
if (await storage.getString('authenticated') != null) {
return true;
}
return false;
}
Future<bool> checkConnection() async { Future<bool> checkConnection() async {
if (await InternetConnection().hasInternetAccess) { if (await InternetConnection().hasInternetAccess) {
_previousConnection = true; _previousConnection = true;
return true; return true;
} else { } else {
if (_previousConnection) { if (_previousConnection) {
// showErrorToast('Check your internet connection');
_previousConnection = false; _previousConnection = false;
} }
@ -43,6 +37,7 @@ class StatusCheckerService {
} }
} }
// Check phone available storage
Future<int> getAvailableStorage() async { Future<int> getAvailableStorage() async {
try { try {
final availableStorage = final availableStorage =
@ -53,6 +48,7 @@ class StatusCheckerService {
} }
} }
// Check for latest update
Future<void> checkAndUpdate() async { Future<void> checkAndUpdate() async {
const requiredStorage = 500 * 1024 * 1024; const requiredStorage = 500 * 1024 * 1024;
@ -62,16 +58,12 @@ class StatusCheckerService {
await getAvailableStorage(); // Implement getAvailableStorage await getAvailableStorage(); // Implement getAvailableStorage
if (batteryLevel < 20 || storageAvailable < requiredStorage) { if (batteryLevel < 20 || storageAvailable < requiredStorage) {
if (batteryLevel < 20 || storageAvailable < requiredStorage) { if (batteryLevel < 20 || storageAvailable < requiredStorage) {
// KewedeConst().showErrorToast(
// 'Unable to update app, please charge your phone & free up space.'); // 'Unable to update app, please charge your phone & free up space.');
} else if (batteryLevel < 20) { } else if (batteryLevel < 20) {
// KewedeConst()
// .showErrorToast('Unable to update app, please charge your phone.'); // .showErrorToast('Unable to update app, please charge your phone.');
} else if (storageAvailable < requiredStorage) { } else if (storageAvailable < requiredStorage) {
// KewedeConst()
// .showErrorToast('Unable to update app, please free up space.'); // .showErrorToast('Unable to update app, please free up space.');
} }
// Show user-friendly message explaining why update failed and suggesting solutions (e.g., charge device, free up space)
return; // Prevent update from starting return; // Prevent update from starting
} }
try { try {

View File

@ -0,0 +1,35 @@
import 'package:stacked/stacked.dart';
import 'package:waveform_recorder/waveform_recorder.dart';
import 'package:yimaru_app/ui/common/enmus.dart';
class VoiceRecorderService with ListenableServiceMixin {
VoiceRecordingState _recordingState = VoiceRecordingState.pending;
VoiceRecordingState get recordingState => _recordingState;
final WaveformRecorderController _waveController =
WaveformRecorderController();
WaveformRecorderController get waveController => _waveController;
Future<void> startRecording() async {
await _waveController.startRecording();
_recordingState = VoiceRecordingState.recording;
notifyListeners();
}
Future<void> stopRecording() async {
await _waveController.stopRecording();
_recordingState = VoiceRecordingState.pending;
notifyListeners();
}
Future<String?> getRecordedAudio() async {
final file = _waveController.file;
print('RECORDED $file');
if (file == null) return null;
return file.path;
}
}

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
const Color kcBlack = Colors.black; const Color kcBlack = Colors.black;
const Color kcRed = Color(0xffFF4C4C); const Color kcRed = Color(0xffFF4C4C);
const Color kcBlue = Color(0xff135BEC);
const Color kcGreen = Color(0xFF1DE964); const Color kcGreen = Color(0xFF1DE964);
const Color kcBackgroundColor = kcWhite; const Color kcBackgroundColor = kcWhite;
const Color kcWhite = Color(0xFFFFFFFF); const Color kcWhite = Color(0xFFFFFFFF);
@ -10,6 +11,7 @@ const Color kcIndigo = Color(0xff6A1B9A);
const Color kcOrange = Color(0xFFF79400); const Color kcOrange = Color(0xFFF79400);
const Color kcSkyBlue = Color(0xFF28B4CD); const Color kcSkyBlue = Color(0xFF28B4CD);
const Color kcDarkGrey = Color(0xFF1A1B1E); const Color kcDarkGrey = Color(0xFF1A1B1E);
const Color kcDeepGreen = Color(0xFF078E37);
const Color kcMediumGrey = Color(0xFF474A54); const Color kcMediumGrey = Color(0xFF474A54);
const Color kcAquamarine = Color(0xFF1DE9B6); const Color kcAquamarine = Color(0xFF1DE9B6);
const Color kcTransparent = Colors.transparent; const Color kcTransparent = Colors.transparent;

View File

@ -1,34 +1,58 @@
String kBaseUrl = 'http://195.35.29.82:8080'; String kBaseUrl = 'https://api.yimaruacademy.com';
//String baseUrl = 'https://api.yimaru.yaltopia.com'; //String baseUrl = 'https://api.yimaru.yaltopia.com';
String kGetUserUrl = 'single'; String kCoursesUrl = 'courses';
String kUserUrl = 'api/v1/user';
String kRegisterUrl = 'register'; String kRegisterUrl = 'register';
String kCoursePractice = 'by-owner';
String kUserBaseUrl = 'api/v1/user';
String kVerifyOtpUrl = 'verify-otp'; String kVerifyOtpUrl = 'verify-otp';
String kResendOtpUrl = 'resend-otp'; String kResendOtpUrl = 'resend-otp';
String kGetUserUrl = 'user-profile';
String kSubcoursesUrl = 'sub-courses';
String kCompleteLessonUrl = 'complete';
String kResetPassword = 'resetPassword'; String kResetPassword = 'resetPassword';
String kCourseCategoryUrl = 'categories';
String kRequestResetCode = 'sendResetCode'; String kRequestResetCode = 'sendResetCode';
String kPublishedVideos = 'videos/published';
String kCoursePracticeQuestions = 'questions';
String kUpdateProfileImage = 'profile-picture'; String kUpdateProfileImage = 'profile-picture';
String kRefreshTokenUrl = 'api/v1/auth/refresh'; String kRefreshTokenUrl = 'api/v1/auth/refresh';
String kLoginUrl = 'api/v1/auth/customer-login'; String kLoginUrl = 'api/v1/auth/customer-login';
String kPracticeBaseUrl = 'api/v1/question-sets';
String kProfileStatusUrl = 'is-profile-completed'; String kProfileStatusUrl = 'is-profile-completed';
String kCourseBaseUrl = 'api/v1/course-management';
String kLessonProgressUrl = 'api/v1/progress/videos';
String kGoogleAuthUrl = 'api/v1/auth/google/android'; String kGoogleAuthUrl = 'api/v1/auth/google/android';
String kCourseProgressUrl = 'api/v1/progress/courses';
String kAssessmentsUrl = 'api/v1/assessment/questions'; String kAssessmentsUrl = 'api/v1/assessment/questions';
String kServerClientId = String kEmptyImagePath = '/data/user/0/com.yimaru.lms.app/app_flutter';
'574860813475-n5o17gpprdqmhcml99tiqhafb17rob0r.apps.googleusercontent.com';
String kSampleVideoUrl = String kSampleVideoUrl =
'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4'; 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4';
String kServerClientId =
'574860813475-n5o17gpprdqmhcml99tiqhafb17rob0r.apps.googleusercontent.com';

View File

@ -1,11 +1,17 @@
const String ksHomeBottomSheetTitle = 'Build Great Apps!';
const String ksSuggestion = const String ksSuggestion =
"15 minutes a day can make you 3x more fluent in 3 month"; "15 minutes a day can make you 3x more fluent in 3 month";
const String ksHomeBottomSheetTitle = 'Build Great Apps!';
const String ksPrivacyPolicy =
'A brief, simple overview of Yimarus commitment to user privacy. Our goal is to be transparent about the data we collect and how we use it to enhance your learning experience.';
const String ksHomeBottomSheetDescription = const String ksHomeBottomSheetDescription =
'Stacked is built to help you build better apps. Give us a chance and we\'ll prove it to you. Check out stacked.filledstacks.com to learn more'; 'Stacked is built to help you build better apps. Give us a chance and we\'ll prove it to you. Check out stacked.filledstacks.com to learn more';
const String ksPrivacyPolicy =
'A brief, simple overview of Yimarus commitment to user privacy. Our goal is to be transparent about the data we collect and how we use it to enhance your learning experience.';
const String ksCategorySubtitle =
'Watch expert-led videos and reinforce your knowledge through guided practice activities.';
const String ksTerms = """ const String ksTerms = """
<p style="color:#9C2C91;font-size:13px;"> <p style="color:#9C2C91;font-size:13px;">
Last updated: October 26, 2025 Last updated: October 26, 2025

View File

@ -1,26 +1,50 @@
// Registration type // Login method
enum RegistrationType { phone, email } enum LoginMethod { phone, email, google }
// Report status // Response status
enum ResponseStatus { success, failure } enum ResponseStatus { success, failure }
enum ProgressStatuses { pending, started, completed } // Sign-up method
enum SignUpMethod { phone, email, google }
// Voice recording state
enum VoiceRecordingState { pending, recording }
// Levels // Levels
enum ProficiencyLevels { a1, a2, b1, b2, none } enum ProficiencyLevels { a1, a2, b1, b2, none }
// Progress status
enum ProgressStatuses { pending, started, completed }
// Duolingo assessment types
enum DuolingoAssessmentType { speaking, reading, writing, listening }
// State object // State object
enum StateObjects { enum StateObjects {
none,
courses,
homeView,
register,
verifyOtp, verifyOtp,
resendOtp, resendOtp,
profileImage, profileImage,
courseLessons,
profileUpdate, profileUpdate,
resetPassword, resetPassword,
subcategories,
loginWithEmail, loginWithEmail,
coursePractice,
loginWithGoogle, loginWithGoogle,
loadLessonVideo, loadLessonVideo,
loadCourseVideo,
requestResetCode, requestResetCode,
registerWithEmail, courseCategories,
profileCompletion, profileCompletion,
registerWithGoogle, registerWithGoogle,
learnPracticeSample,
learnPracticeAnswer,
loginWithPhoneNumber,
learnPracticeQuestion,
recordLearnPracticeAnswer,
} }

View File

@ -1,3 +1,9 @@
// Split full name
import 'dart:math';
import 'dart:ui';
import 'app_colors.dart';
Map<String, String> splitFullName(String fullName) { Map<String, String> splitFullName(String fullName) {
final parts = fullName.trim().split(RegExp(r'\s+')); final parts = fullName.trim().split(RegExp(r'\s+'));
@ -15,3 +21,50 @@ Map<String, String> splitFullName(String fullName) {
'last_name': parts.sublist(1).join(' '), 'last_name': parts.sublist(1).join(' '),
}; };
} }
Color getColor() {
final generator = Random();
int random = generator.nextInt(8);
if (random == 1) {
return kcRed.withValues(alpha: 0.2);
} else if (random == 2) {
return kcPrimaryColor.withValues(alpha: 0.2);
} else if (random == 3) {
return kcOrange.withValues(alpha: 0.2);
} else if (random == 4) {
return kcGreen.withValues(alpha: 0.2);
} else if (random == 5) {
return kcBlue.withValues(alpha: 0.2);
} else if (random == 6) {
return kcSkyBlue.withValues(alpha: 0.2);
} else if (random == 7) {
return kcIndigo.withValues(alpha: 0.2);
} else {
return kcAquamarine.withValues(alpha: 0.2);
}
}
String? getPlayableUrl(String url) {
try {
// Case 1: /file/d/FILE_ID/view
final fileIdRegex = RegExp(r'/file/d/([a-zA-Z0-9_-]+)');
final match1 = fileIdRegex.firstMatch(url);
if (match1 != null) {
final fileId = match1.group(1);
return "https://drive.google.com/uc?export=download&id=$fileId";
}
// Case 2: open?id=FILE_ID
final uri = Uri.parse(url);
if (uri.queryParameters.containsKey('id')) {
final fileId = uri.queryParameters['id'];
return "https://drive.google.com/uc?export=download&id=$fileId";
}
// Already converted or normal URL
return url;
} catch (e) {
return null;
}
}

View File

@ -1,8 +1,9 @@
import 'dart:math'; import 'dart:math';
import 'package:chewie/chewie.dart'; import 'package:chewie/chewie.dart';
import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_html/flutter_html.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:flutter/material.dart';
import 'package:pinput/pinput.dart'; import 'package:pinput/pinput.dart';
import 'package:toastification/toastification.dart'; import 'package:toastification/toastification.dart';
import 'package:yimaru_app/ui/common/app_colors.dart'; import 'package:yimaru_app/ui/common/app_colors.dart';
@ -178,6 +179,12 @@ TextStyle style18P600 = const TextStyle(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
); );
TextStyle style16W600 = const TextStyle(
fontSize: 16,
color: kcWhite,
fontWeight: FontWeight.w600,
);
TextStyle style18W600 = const TextStyle( TextStyle style18W600 = const TextStyle(
fontSize: 18, fontSize: 18,
color: kcWhite, color: kcWhite,
@ -190,6 +197,11 @@ TextStyle style25W600 = const TextStyle(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
); );
TextStyle style12RP600 = const TextStyle(
fontSize: 12,
color: kcPrimaryColor,
fontWeight: FontWeight.w600,
);
TextStyle style12R700 = const TextStyle( TextStyle style12R700 = const TextStyle(
fontSize: 12, fontSize: 12,
@ -197,10 +209,24 @@ TextStyle style12R700 = const TextStyle(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
); );
TextStyle style12P400 = const TextStyle(
fontSize: 12,
color: kcPrimaryColor,
);
TextStyle style12DG400 = const TextStyle(
fontSize: 12,
color: kcDarkGrey,
);
TextStyle style14P400 = const TextStyle( TextStyle style14P400 = const TextStyle(
color: kcPrimaryColor, color: kcPrimaryColor,
); );
TextStyle style14B400 = const TextStyle(
color: kcBlue,
);
TextStyle style14P600 = const TextStyle( TextStyle style14P600 = const TextStyle(
color: kcPrimaryColor, color: kcPrimaryColor,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -218,12 +244,29 @@ TextStyle style25DG600 = const TextStyle(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
); );
TextStyle style16P600 = const TextStyle(
fontSize: 16,
color: kcPrimaryColor,
fontWeight: FontWeight.w600,
);
TextStyle style16DG500 = const TextStyle(
fontSize: 16,
color: kcDarkGrey,
);
TextStyle style16DG600 = const TextStyle( TextStyle style16DG600 = const TextStyle(
fontSize: 16, fontSize: 16,
color: kcDarkGrey, color: kcDarkGrey,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
); );
TextStyle style16B600 = const TextStyle(
fontSize: 16,
color: kcBlue,
fontWeight: FontWeight.w600,
);
TextStyle style18DG500 = const TextStyle( TextStyle style18DG500 = const TextStyle(
fontSize: 18, fontSize: 18,
color: kcDarkGrey, color: kcDarkGrey,
@ -236,6 +279,24 @@ TextStyle style18DG600 = const TextStyle(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
); );
TextStyle style18G700 = const TextStyle(
fontSize: 18,
color: kcDeepGreen,
fontWeight: FontWeight.w700,
);
TextStyle style18DG700 = const TextStyle(
fontSize: 18,
color: kcDarkGrey,
fontWeight: FontWeight.w700,
);
TextStyle style20DG700 = const TextStyle(
fontSize: 20,
color: kcDarkGrey,
fontWeight: FontWeight.w700,
);
TextStyle style16DG400 = const TextStyle( TextStyle style16DG400 = const TextStyle(
fontSize: 16, fontSize: 16,
color: kcDarkGrey, color: kcDarkGrey,
@ -266,6 +327,8 @@ TextStyle validationStyle = const TextStyle(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
); );
Duration kDuration = const Duration(seconds: 1);
Style htmlDefaultStyle = Style(color: kcDarkGrey, fontSize: FontSize(16)); Style htmlDefaultStyle = Style(color: kcDarkGrey, fontSize: FontSize(16));
Map<String, Style> htmlStyle = { Map<String, Style> htmlStyle = {
@ -296,13 +359,19 @@ ChewieProgressColors buildChewieProgressIndicator = ChewieProgressColors(
Widget buildToastDescription(String message) => Text( Widget buildToastDescription(String message) => Text(
message, message,
maxLines: 4, maxLines: 4,
style: const TextStyle(color: kcDarkGrey, fontWeight: FontWeight.w500), style: style14DG500,
);
Icon buildCloseIcon() => const Icon(
Icons.close,
color: kcPrimaryColor,
); );
void showErrorToast(String message) { void showErrorToast(String message) {
toastification.show( toastification.show(
showIcon: true, showIcon: true,
dragToClose: true, dragToClose: true,
icon: buildCloseIcon(),
showProgressBar: false, showProgressBar: false,
applyBlurEffect: false, applyBlurEffect: false,
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
@ -313,19 +382,21 @@ void showErrorToast(String message) {
autoCloseDuration: const Duration(seconds: 3), autoCloseDuration: const Duration(seconds: 3),
margin: const EdgeInsets.symmetric(horizontal: 15), margin: const EdgeInsets.symmetric(horizontal: 15),
borderSide: const BorderSide(color: kcPrimaryColor), borderSide: const BorderSide(color: kcPrimaryColor),
icon: const Icon(
Icons.close,
color: kcPrimaryColor,
),
); );
} }
Icon buildCheckIcon() => const Icon(
Icons.check,
color: kcPrimaryColor,
);
void showSuccessToast(String message) { void showSuccessToast(String message) {
toastification.show( toastification.show(
showIcon: true, showIcon: true,
dragToClose: true, dragToClose: true,
showProgressBar: false, showProgressBar: false,
applyBlurEffect: false, applyBlurEffect: false,
icon: buildCheckIcon(),
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
primaryColor: kcBackgroundColor, primaryColor: kcBackgroundColor,
type: ToastificationType.success, type: ToastificationType.success,
@ -334,9 +405,5 @@ void showSuccessToast(String message) {
autoCloseDuration: const Duration(seconds: 3), autoCloseDuration: const Duration(seconds: 3),
margin: const EdgeInsets.symmetric(horizontal: 15), margin: const EdgeInsets.symmetric(horizontal: 15),
borderSide: const BorderSide(color: kcPrimaryColor), borderSide: const BorderSide(color: kcPrimaryColor),
icon: const Icon(
Icons.check,
color: kcPrimaryColor,
),
); );
} }

View File

@ -1,18 +1,67 @@
import 'package:email_validator/email_validator.dart'; import 'package:email_validator/email_validator.dart';
class FormValidator { class FormValidator {
// Form validator
static String? validateForm(String? value) { static String? validateForm(String? value) {
if (value == null) { if (value == null) {
return null; return null;
} }
if (value.isEmpty) {
return 'The field is required';
}
return null;
}
// Form validator
static String? validateFullNameForm(String? value) {
if (value == null) {
return null;
}
if (value.isEmpty) {
return 'The field is required';
}
final regex = RegExp(r'^\S+\s+\S+.*$');
if (!regex.hasMatch(value.trim())) {
return "Enter your full name";
}
return null;
}
// Email validator
static String? validateEmailForm(String? value) {
if (value == null) {
return null;
}
if (value.isEmpty) {
return 'The field is required';
}
if (!EmailValidator.validate(value)) {
return 'Invalid email format';
}
return null;
}
// Password validator
static String? validatePasswordForm(String? value) {
if (value == null) {
return null;
}
if (value.isEmpty) { if (value.isEmpty) {
return 'The field is required'; return 'The field is required';
} }
return null; return null;
} }
static String? validatePhoneNumber(String? value) { // Phone number validator
static String? validatePhoneNumberForm(String? value) {
if (value == null) { if (value == null) {
return null; return null;
} }
@ -34,31 +83,4 @@ class FormValidator {
} }
return null; return null;
} }
static String? validateEmail(String? value) {
if (value == null) {
return null;
}
if (value.isEmpty) {
return 'The field is required';
}
if (!EmailValidator.validate(value)) {
return 'Invalid email format';
}
return null;
}
static String? validatePassword(String? value) {
if (value == null) {
return null;
}
if (value.isEmpty) {
return 'The field is required';
}
return null;
}
} }

View File

@ -12,9 +12,7 @@ class AccountPrivacyView extends StackedView<AccountPrivacyViewModel> {
const AccountPrivacyView({Key? key}) : super(key: key); const AccountPrivacyView({Key? key}) : super(key: key);
@override @override
AccountPrivacyViewModel viewModelBuilder( AccountPrivacyViewModel viewModelBuilder(BuildContext context) =>
BuildContext context,
) =>
AccountPrivacyViewModel(); AccountPrivacyViewModel();
@override @override
@ -57,8 +55,9 @@ class AccountPrivacyView extends StackedView<AccountPrivacyViewModel> {
); );
Widget _buildAppbar(AccountPrivacyViewModel viewModel) => SmallAppBar( Widget _buildAppbar(AccountPrivacyViewModel viewModel) => SmallAppBar(
title: 'Account Privacy', showBackButton: true,
onTap: viewModel.pop, onTap: viewModel.pop,
title: 'Account Privacy',
); );
Widget _buildContentWrapper(AccountPrivacyViewModel viewModel) => Widget _buildContentWrapper(AccountPrivacyViewModel viewModel) =>
@ -107,7 +106,7 @@ class AccountPrivacyView extends StackedView<AccountPrivacyViewModel> {
Widget _buildHeader(String title) => Text( Widget _buildHeader(String title) => Text(
title, title,
style: style18DG600, style: style18DG700,
); );
Widget _buildLanguageMenu(AccountPrivacyViewModel viewModel) => Widget _buildLanguageMenu(AccountPrivacyViewModel viewModel) =>
@ -146,8 +145,8 @@ class AccountPrivacyView extends StackedView<AccountPrivacyViewModel> {
); );
Widget _buildDeleteButton() => CustomElevatedButton( Widget _buildDeleteButton() => CustomElevatedButton(
height: 55, height: 55,
text: 'Delete Account',
borderRadius: 12, borderRadius: 12,
text: 'Delete Account',
foregroundColor: kcRed, foregroundColor: kcRed,
backgroundColor: kcRed.withOpacity(0.25), backgroundColor: kcRed.withOpacity(0.25),
); );

View File

@ -5,6 +5,7 @@ import 'package:yimaru_app/app/app.router.dart';
import '../../../app/app.locator.dart'; import '../../../app/app.locator.dart';
class AccountPrivacyViewModel extends BaseViewModel { class AccountPrivacyViewModel extends BaseViewModel {
// Dependency injection
final _navigationService = locator<NavigationService>(); final _navigationService = locator<NavigationService>();
// Navigation // Navigation

View File

@ -1,12 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:stacked/stacked.dart'; import 'package:stacked/stacked.dart';
import 'package:yimaru_app/ui/views/assessment/screens/Assessment_form_screen.dart'; import 'package:yimaru_app/ui/views/assessment/screens/Assessment_form_screen.dart';
import 'package:yimaru_app/ui/views/assessment/screens/assessment_completion_screen.dart';
import 'package:yimaru_app/ui/views/assessment/screens/assessment_failure_screen.dart';
import 'package:yimaru_app/ui/views/assessment/screens/assessment_intro_screen.dart'; import 'package:yimaru_app/ui/views/assessment/screens/assessment_intro_screen.dart';
import 'package:yimaru_app/ui/views/assessment/screens/assessment_result_screen.dart'; import 'package:yimaru_app/ui/views/assessment/screens/assessment_result_screen.dart';
import 'package:yimaru_app/ui/views/assessment/screens/result_analysis_screen.dart';
import 'package:yimaru_app/ui/views/assessment/screens/retake_assessment_screen.dart';
import 'package:yimaru_app/ui/views/assessment/screens/start_lesson_screen.dart'; import 'package:yimaru_app/ui/views/assessment/screens/start_lesson_screen.dart';
import 'assessment_viewmodel.dart'; import 'assessment_viewmodel.dart';
@ -23,13 +19,23 @@ class AssessmentView extends StackedView<AssessmentViewModel> {
super.onViewModelReady(viewModel); super.onViewModelReady(viewModel);
} }
@override
AssessmentViewModel viewModelBuilder(BuildContext context) =>
AssessmentViewModel();
@override @override
Widget builder( Widget builder(
BuildContext context, BuildContext context,
AssessmentViewModel viewModel, AssessmentViewModel viewModel,
Widget? child, Widget? child,
) => ) =>
_buildAssessmentScreens(viewModel); _buildAssessmentScreensWrapper(viewModel);
Widget _buildAssessmentScreensWrapper(AssessmentViewModel viewModel) =>
PopScope(
canPop: viewModel.currentPage == 0 ? true : false,
onPopInvokedWithResult: (value, data) => viewModel.goBack(),
child: _buildAssessmentScreens(viewModel));
Widget _buildAssessmentScreens(AssessmentViewModel viewModel) => IndexedStack( Widget _buildAssessmentScreens(AssessmentViewModel viewModel) => IndexedStack(
index: viewModel.currentPage, index: viewModel.currentPage,
@ -53,21 +59,7 @@ class AssessmentView extends StackedView<AssessmentViewModel> {
Widget _buildAssessment() => const AssessmentFormScreen(); Widget _buildAssessment() => const AssessmentFormScreen();
Widget _buildAssessmentFailure() => const AssessmentFailureScreen();
Widget _buildRetakeAssessment() => const RetakeAssessmentScreen();
Widget _buildResultAnalysis() => const ResultAnalysisScreen();
Widget _buildAssessmentCompletion() => const AssessmentCompletionScreen();
Widget _buildAssessmentResult() => const AssessmentResultScreen(); Widget _buildAssessmentResult() => const AssessmentResultScreen();
Widget _buildStartLesson() => const StartLessonScreen(); Widget _buildStartLesson() => const StartLessonScreen();
@override
AssessmentViewModel viewModelBuilder(
BuildContext context,
) =>
AssessmentViewModel();
} }

View File

@ -1,5 +1,3 @@
import 'dart:math';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:stacked/stacked.dart'; import 'package:stacked/stacked.dart';
import 'package:stacked_services/stacked_services.dart'; import 'package:stacked_services/stacked_services.dart';
@ -13,9 +11,9 @@ import '../../../models/assessment.dart';
import '../../../services/api_service.dart'; import '../../../services/api_service.dart';
import '../../common/app_colors.dart'; import '../../common/app_colors.dart';
import '../../common/ui_helpers.dart'; import '../../common/ui_helpers.dart';
import '../home/home_view.dart';
class AssessmentViewModel extends BaseViewModel { class AssessmentViewModel extends BaseViewModel {
// Dependency injection
final _apiService = locator<ApiService>(); final _apiService = locator<ApiService>();
final _dialogService = locator<DialogService>(); final _dialogService = locator<DialogService>();
final _statusChecker = locator<StatusCheckerService>(); final _statusChecker = locator<StatusCheckerService>();
@ -207,6 +205,7 @@ class AssessmentViewModel extends BaseViewModel {
} }
} }
// In-app navigation
void next({int? page}) async { void next({int? page}) async {
if (page == null) { if (page == null) {
if (_previousPage != 0) { if (_previousPage != 0) {
@ -221,28 +220,37 @@ class AssessmentViewModel extends BaseViewModel {
rebuildUi(); rebuildUi();
} }
void pop() { void goBack() {
if (_currentPage == 0 || _currentPage == 3 /*7*/) { if (_currentPage == 0) {
_navigationService.back(); _navigationService.back();
} else if (_currentPage != 0 && _currentPage != 3) { } else if (_currentPage == 2) {
_currentPage--; _currentPage = 0;
rebuildUi();
} else if (_currentPage == 3) {
if (_proficiencyLevel != ProficiencyLevels.none) {
_currentPage--;
} else {
_currentPage = 0;
}
rebuildUi(); rebuildUi();
} }
} }
// Navigation // Navigation
void pop() => _navigationService.back();
Future<void> navigateToLanguage() async => Future<void> navigateToLanguage() async =>
await _navigationService.navigateToLanguageView(); await _navigationService.navigateToLanguageView();
Future<void> replaceWithHome() async => Future<void> replaceWithHome() async =>
await _navigationService.clearStackAndShowView(const HomeView()); await _navigationService.clearStackAndShow(Routes.homeView);
// Remote api call // Remote api call
Future<void> getAssessments() async => await runBusyFuture(_getAssessments()); Future<void> getAssessments() async => await runBusyFuture(_getAssessments());
Future<void> _getAssessments() async { Future<void> _getAssessments() async {
if (await _statusChecker.checkConnection()) { if (await _statusChecker.checkConnection()) {
List<Assessment> response = await _apiService.getAssessments(); _assessments = await _apiService.getAssessments();
/* /*
for (int i = 0; i < 6; i++) { for (int i = 0; i < 6; i++) {
final generator = Random(); final generator = Random();
@ -250,7 +258,6 @@ class AssessmentViewModel extends BaseViewModel {
response.add(response[random]); response.add(response[random]);
} }
*/ */
_assessments = response;
} }
} }

View File

@ -1,97 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:stacked/stacked.dart';
import 'package:yimaru_app/ui/common/app_colors.dart';
import 'package:yimaru_app/ui/common/ui_helpers.dart';
import 'package:yimaru_app/ui/views/assessment/assessment_viewmodel.dart';
import 'package:yimaru_app/ui/widgets/custom_elevated_button.dart';
import 'package:yimaru_app/ui/widgets/large_app_bar.dart';
class AssessmentCompletionScreen extends ViewModelWidget<AssessmentViewModel> {
const AssessmentCompletionScreen({super.key});
@override
Widget build(BuildContext context, AssessmentViewModel viewModel) =>
_buildScaffoldWrapper(viewModel);
Widget _buildScaffoldWrapper(AssessmentViewModel viewModel) => Scaffold(
backgroundColor: kcBackgroundColor,
body: _buildScaffold(viewModel),
);
Widget _buildScaffold(AssessmentViewModel viewModel) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: _buildScaffoldChildren(viewModel),
);
List<Widget> _buildScaffoldChildren(AssessmentViewModel viewModel) =>
[_buildAppBar(), _buildExpandedBody(viewModel)];
Widget _buildAppBar() => const LargeAppBar(
showBackButton: false,
showLanguageSelection: false,
);
Widget _buildExpandedBody(AssessmentViewModel viewModel) =>
Expanded(child: _buildBodyWrapper(viewModel));
Widget _buildBodyWrapper(AssessmentViewModel viewModel) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: _buildBody(viewModel),
);
Widget _buildBody(AssessmentViewModel viewModel) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: _buildBodyChildren(viewModel),
);
List<Widget> _buildBodyChildren(AssessmentViewModel viewModel) =>
[_buildUpperColumn(viewModel), _buildContinueButtonWrapper(viewModel)];
Widget _buildUpperColumn(AssessmentViewModel viewModel) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: _buildUpperColumnChildren(viewModel),
);
List<Widget> _buildUpperColumnChildren(AssessmentViewModel viewModel) => [
verticalSpaceLarge,
_buildIcon(),
verticalSpaceMedium,
_buildTitle(),
verticalSpaceSmall,
_buildSubtitle(),
];
Widget _buildIcon() => SvgPicture.asset(
'assets/icons/complete.svg',
);
Widget _buildTitle() => Text(
'Assessment complete!',
style: style25DG600,
textAlign: TextAlign.center,
);
Widget _buildSubtitle() => Text(
'Were now analyzing your speaking skills',
textAlign: TextAlign.center,
style: style14MG400,
);
Widget _buildContinueButtonWrapper(AssessmentViewModel viewModel) => Padding(
padding: const EdgeInsets.only(bottom: 50),
child: _buildContinueButton(viewModel),
);
Widget _buildContinueButton(AssessmentViewModel viewModel) =>
CustomElevatedButton(
height: 55,
borderRadius: 12,
text: 'View My Results',
foregroundColor: kcWhite,
onTap: () => viewModel.next(),
backgroundColor: kcPrimaryColor,
);
}

View File

@ -1,121 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:stacked/stacked.dart';
import 'package:yimaru_app/ui/common/app_colors.dart';
import 'package:yimaru_app/ui/common/ui_helpers.dart';
import 'package:yimaru_app/ui/widgets/custom_elevated_button.dart';
import 'package:yimaru_app/ui/widgets/large_app_bar.dart';
import '../assessment_viewmodel.dart';
class AssessmentFailureScreen extends ViewModelWidget<AssessmentViewModel> {
const AssessmentFailureScreen({super.key});
@override
Widget build(BuildContext context, AssessmentViewModel viewModel) =>
_buildScaffoldWrapper(viewModel);
Widget _buildScaffoldWrapper(AssessmentViewModel viewModel) => Scaffold(
backgroundColor: kcBackgroundColor,
body: _buildScaffold(viewModel),
);
Widget _buildScaffold(AssessmentViewModel viewModel) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: _buildScaffoldChildren(viewModel),
);
List<Widget> _buildScaffoldChildren(AssessmentViewModel viewModel) =>
[_buildAppBar(viewModel), _buildExpandedBody(viewModel)];
Widget _buildAppBar(AssessmentViewModel viewModel) => LargeAppBar(
showBackButton: false,
showLanguageSelection: true,
onLanguage: () async => await viewModel.navigateToLanguage(),
);
Widget _buildExpandedBody(AssessmentViewModel viewModel) =>
Expanded(child: _buildBodyWrapper(viewModel));
Widget _buildBodyWrapper(AssessmentViewModel viewModel) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: _buildBody(viewModel),
);
Widget _buildBody(AssessmentViewModel viewModel) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: _buildBodyChildren(viewModel),
);
List<Widget> _buildBodyChildren(AssessmentViewModel viewModel) =>
[_buildUpperColumn(viewModel), _buildLowerColumn(viewModel)];
Widget _buildUpperColumn(AssessmentViewModel viewModel) => Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: _buildUpperColumnChildren(viewModel),
);
List<Widget> _buildUpperColumnChildren(AssessmentViewModel viewModel) => [
verticalSpaceLarge,
_buildIcon(),
verticalSpaceMedium,
_buildTitle(),
verticalSpaceSmall,
_buildSubtitle(),
];
Widget _buildIcon() => SvgPicture.asset('assets/icons/alert.svg');
Widget _buildTitle() => Text(
'We didnt get enough from your assessment',
style: style25DG600,
textAlign: TextAlign.center,
);
Widget _buildSubtitle() => Text(
'Your assessment wasnt long enough for us to analyze your speaking level. You can retake the call to get accurate results ',
textAlign: TextAlign.center,
style: style14MG400,
);
Widget _buildLowerColumn(AssessmentViewModel viewModel) => Column(
mainAxisSize: MainAxisSize.min,
children: _buildLowerColumnChildren(viewModel),
);
List<Widget> _buildLowerColumnChildren(AssessmentViewModel viewModel) => [
_buildContinueButton(viewModel),
verticalSpaceSmall,
_buildSkipButtonWrapper(viewModel)
];
Widget _buildContinueButton(AssessmentViewModel viewModel) =>
CustomElevatedButton(
height: 55,
safe: false,
borderRadius: 12,
text: 'Continue Assessment',
onTap: () => viewModel.next(),
foregroundColor: kcWhite,
backgroundColor: kcPrimaryColor,
);
Widget _buildSkipButtonWrapper(AssessmentViewModel viewModel) => Padding(
padding: const EdgeInsets.only(bottom: 50),
child: _buildSkipButton(viewModel),
);
Widget _buildSkipButton(AssessmentViewModel viewModel) =>
CustomElevatedButton(
height: 55,
text: 'Skip',
borderRadius: 12,
backgroundColor: kcWhite,
borderColor: kcPrimaryColor,
onTap: () => viewModel.next(),
foregroundColor: kcPrimaryColor,
);
}

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