From ad195d4291cddcbf6ca7c454e7b6fa85cfd44595 Mon Sep 17 00:00:00 2001 From: ved <1362943790@qq.com> Date: Thu, 15 Dec 2022 00:56:32 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4=E4=BA=86=E5=85=B3=E4=BA=8E?= =?UTF-8?q?=E7=99=BB=E9=99=86=E7=95=8C=E9=9D=A2=E3=80=81=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E7=95=8C=E9=9D=A2=E3=80=81=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E7=9A=84=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../firefly/ExampleInstrumentedTest.java | 26 +++ src/main/AndroidManifest.xml | 33 ++++ .../example/firefly/data/LoginDataSource.java | 29 +++ .../example/firefly/data/LoginRepository.java | 54 ++++++ .../java/com/example/firefly/data/Result.java | 48 +++++ .../firefly/data/model/LoggedInUser.java | 23 +++ .../firefly/ui/login/LoggedInUserView.java | 17 ++ .../firefly/ui/login/LoginActivity.java | 131 ++++++++++++++ .../firefly/ui/login/LoginFormState.java | 40 +++++ .../example/firefly/ui/login/LoginResult.java | 31 ++++ .../firefly/ui/login/LoginViewModel.java | 70 ++++++++ .../ui/login/LoginViewModelFactory.java | 26 +++ .../firefly/ui/settings/SettingsActivity.java | 31 ++++ .../drawable-v24/ic_launcher_foreground.xml | 30 ++++ .../res/drawable/ic_launcher_background.xml | 170 ++++++++++++++++++ src/main/res/layout/activity_ip_connect.xml | 124 +++++++++++++ src/main/res/layout/activity_login.xml | 76 ++++++++ src/main/res/layout/root_preferences.xml | 46 +++++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 3593 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 5339 bytes src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2636 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 3388 bytes src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 4926 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 7472 bytes src/main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 7909 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 11873 bytes src/main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 10652 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 16570 bytes src/main/res/values/arrays.xml | 20 +++ src/main/res/values/colors.xml | 6 + src/main/res/values/dimens.xml | 5 + src/main/res/values/strings.xml | 30 ++++ src/main/res/values/styles.xml | 10 ++ .../com/example/firefly/ExampleUnitTest.java | 17 ++ 36 files changed, 1103 insertions(+) create mode 100644 src/androidTest/java/com/example/firefly/ExampleInstrumentedTest.java create mode 100644 src/main/AndroidManifest.xml create mode 100644 src/main/java/com/example/firefly/data/LoginDataSource.java create mode 100644 src/main/java/com/example/firefly/data/LoginRepository.java create mode 100644 src/main/java/com/example/firefly/data/Result.java create mode 100644 src/main/java/com/example/firefly/data/model/LoggedInUser.java create mode 100644 src/main/java/com/example/firefly/ui/login/LoggedInUserView.java create mode 100644 src/main/java/com/example/firefly/ui/login/LoginActivity.java create mode 100644 src/main/java/com/example/firefly/ui/login/LoginFormState.java create mode 100644 src/main/java/com/example/firefly/ui/login/LoginResult.java create mode 100644 src/main/java/com/example/firefly/ui/login/LoginViewModel.java create mode 100644 src/main/java/com/example/firefly/ui/login/LoginViewModelFactory.java create mode 100644 src/main/java/com/example/firefly/ui/settings/SettingsActivity.java create mode 100644 src/main/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 src/main/res/drawable/ic_launcher_background.xml create mode 100644 src/main/res/layout/activity_ip_connect.xml create mode 100644 src/main/res/layout/activity_login.xml create mode 100644 src/main/res/layout/root_preferences.xml create mode 100644 src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 src/main/res/values/arrays.xml create mode 100644 src/main/res/values/colors.xml create mode 100644 src/main/res/values/dimens.xml create mode 100644 src/main/res/values/strings.xml create mode 100644 src/main/res/values/styles.xml create mode 100644 src/test/java/com/example/firefly/ExampleUnitTest.java diff --git a/src/androidTest/java/com/example/firefly/ExampleInstrumentedTest.java b/src/androidTest/java/com/example/firefly/ExampleInstrumentedTest.java new file mode 100644 index 0000000..3a9bd15 --- /dev/null +++ b/src/androidTest/java/com/example/firefly/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.example.firefly; + +import android.content.Context; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + @Test + public void useAppContext() { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + assertEquals("com.example.firefly", appContext.getPackageName()); + } +} \ No newline at end of file diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0d2836b --- /dev/null +++ b/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/java/com/example/firefly/data/LoginDataSource.java b/src/main/java/com/example/firefly/data/LoginDataSource.java new file mode 100644 index 0000000..8be8381 --- /dev/null +++ b/src/main/java/com/example/firefly/data/LoginDataSource.java @@ -0,0 +1,29 @@ +package com.example.firefly.data; + +import com.example.firefly.data.model.LoggedInUser; + +import java.io.IOException; + +/** + * Class that handles authentication w/ login credentials and retrieves user information. + */ +public class LoginDataSource { + + public Result login(String username, String password) { + + try { + // TODO: handle loggedInUser authentication + LoggedInUser fakeUser = + new LoggedInUser( + java.util.UUID.randomUUID().toString(), + "Jane Doe"); + return new Result.Success<>(fakeUser); + } catch (Exception e) { + return new Result.Error(new IOException("Error logging in", e)); + } + } + + public void logout() { + // TODO: revoke authentication + } +} \ No newline at end of file diff --git a/src/main/java/com/example/firefly/data/LoginRepository.java b/src/main/java/com/example/firefly/data/LoginRepository.java new file mode 100644 index 0000000..7e885b9 --- /dev/null +++ b/src/main/java/com/example/firefly/data/LoginRepository.java @@ -0,0 +1,54 @@ +package com.example.firefly.data; + +import com.example.firefly.data.model.LoggedInUser; + +/** + * Class that requests authentication and user information from the remote data source and + * maintains an in-memory cache of login status and user credentials information. + */ +public class LoginRepository { + + private static volatile LoginRepository instance; + + private LoginDataSource dataSource; + + // If user credentials will be cached in local storage, it is recommended it be encrypted + // @see https://developer.android.com/training/articles/keystore + private LoggedInUser user = null; + + // private constructor : singleton access + private LoginRepository(LoginDataSource dataSource) { + this.dataSource = dataSource; + } + + public static LoginRepository getInstance(LoginDataSource dataSource) { + if (instance == null) { + instance = new LoginRepository(dataSource); + } + return instance; + } + + public boolean isLoggedIn() { + return user != null; + } + + public void logout() { + user = null; + dataSource.logout(); + } + + private void setLoggedInUser(LoggedInUser user) { + this.user = user; + // If user credentials will be cached in local storage, it is recommended it be encrypted + // @see https://developer.android.com/training/articles/keystore + } + + public Result login(String username, String password) { + // handle login + Result result = dataSource.login(username, password); + if (result instanceof Result.Success) { + setLoggedInUser(((Result.Success) result).getData()); + } + return result; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/firefly/data/Result.java b/src/main/java/com/example/firefly/data/Result.java new file mode 100644 index 0000000..fc531d5 --- /dev/null +++ b/src/main/java/com/example/firefly/data/Result.java @@ -0,0 +1,48 @@ +package com.example.firefly.data; + +/** + * A generic class that holds a result success w/ data or an error exception. + */ +public class Result { + // hide the private constructor to limit subclass types (Success, Error) + private Result() { + } + + @Override + public String toString() { + if (this instanceof Result.Success) { + Result.Success success = (Result.Success) this; + return "Success[data=" + success.getData().toString() + "]"; + } else if (this instanceof Result.Error) { + Result.Error error = (Result.Error) this; + return "Error[exception=" + error.getError().toString() + "]"; + } + return ""; + } + + // Success sub-class + public final static class Success extends Result { + private T data; + + public Success(T data) { + this.data = data; + } + + public T getData() { + return this.data; + } + } + + // Error sub-class + public final static class Error extends Result { + private Exception error; + + public Error(Exception error) { + this.error = error; + } + + public Exception getError() { + return this.error; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/firefly/data/model/LoggedInUser.java b/src/main/java/com/example/firefly/data/model/LoggedInUser.java new file mode 100644 index 0000000..695d9ff --- /dev/null +++ b/src/main/java/com/example/firefly/data/model/LoggedInUser.java @@ -0,0 +1,23 @@ +package com.example.firefly.data.model; + +/** + * Data class that captures user information for logged in users retrieved from LoginRepository + */ +public class LoggedInUser { + + private String userId; + private String displayName; + + public LoggedInUser(String userId, String displayName) { + this.userId = userId; + this.displayName = displayName; + } + + public String getUserId() { + return userId; + } + + public String getDisplayName() { + return displayName; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/firefly/ui/login/LoggedInUserView.java b/src/main/java/com/example/firefly/ui/login/LoggedInUserView.java new file mode 100644 index 0000000..a722840 --- /dev/null +++ b/src/main/java/com/example/firefly/ui/login/LoggedInUserView.java @@ -0,0 +1,17 @@ +package com.example.firefly.ui.login; + +/** + * Class exposing authenticated user details to the UI. + */ +class LoggedInUserView { + private String displayName; + //... other data fields that may be accessible to the UI + + LoggedInUserView(String displayName) { + this.displayName = displayName; + } + + String getDisplayName() { + return displayName; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/firefly/ui/login/LoginActivity.java b/src/main/java/com/example/firefly/ui/login/LoginActivity.java new file mode 100644 index 0000000..603fce2 --- /dev/null +++ b/src/main/java/com/example/firefly/ui/login/LoginActivity.java @@ -0,0 +1,131 @@ +package com.example.firefly.ui.login; + +import android.app.Activity; + +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModelProviders; + +import android.os.Bundle; + +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.app.AppCompatActivity; + +import android.text.Editable; +import android.text.TextWatcher; +import android.view.KeyEvent; +import android.view.View; +import android.view.inputmethod.EditorInfo; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ProgressBar; +import android.widget.TextView; +import android.widget.Toast; + +import com.example.firefly.R; +import com.example.firefly.ui.login.LoginViewModel; +import com.example.firefly.ui.login.LoginViewModelFactory; + +public class LoginActivity extends AppCompatActivity { + + private LoginViewModel loginViewModel; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_login); + loginViewModel = ViewModelProviders.of(this, new LoginViewModelFactory()) + .get(LoginViewModel.class); + + final EditText usernameEditText = findViewById(R.id.username); + final EditText passwordEditText = findViewById(R.id.password); + final Button loginButton = findViewById(R.id.login); + final ProgressBar loadingProgressBar = findViewById(R.id.loading); + + loginViewModel.getLoginFormState().observe(this, new Observer() { + @Override + public void onChanged(@Nullable LoginFormState loginFormState) { + if (loginFormState == null) { + return; + } + loginButton.setEnabled(loginFormState.isDataValid()); + if (loginFormState.getUsernameError() != null) { + usernameEditText.setError(getString(loginFormState.getUsernameError())); + } + if (loginFormState.getPasswordError() != null) { + passwordEditText.setError(getString(loginFormState.getPasswordError())); + } + } + }); + + loginViewModel.getLoginResult().observe(this, new Observer() { + @Override + public void onChanged(@Nullable LoginResult loginResult) { + if (loginResult == null) { + return; + } + loadingProgressBar.setVisibility(View.GONE); + if (loginResult.getError() != null) { + showLoginFailed(loginResult.getError()); + } + if (loginResult.getSuccess() != null) { + updateUiWithUser(loginResult.getSuccess()); + } + setResult(Activity.RESULT_OK); + + //Complete and destroy login activity once successful + finish(); + } + }); + + TextWatcher afterTextChangedListener = new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + // ignore + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + // ignore + } + + @Override + public void afterTextChanged(Editable s) { + loginViewModel.loginDataChanged(usernameEditText.getText().toString(), + passwordEditText.getText().toString()); + } + }; + usernameEditText.addTextChangedListener(afterTextChangedListener); + passwordEditText.addTextChangedListener(afterTextChangedListener); + passwordEditText.setOnEditorActionListener(new TextView.OnEditorActionListener() { + + @Override + public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { + if (actionId == EditorInfo.IME_ACTION_DONE) { + loginViewModel.login(usernameEditText.getText().toString(), + passwordEditText.getText().toString()); + } + return false; + } + }); + + loginButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + loadingProgressBar.setVisibility(View.VISIBLE); + loginViewModel.login(usernameEditText.getText().toString(), + passwordEditText.getText().toString()); + } + }); + } + + private void updateUiWithUser(LoggedInUserView model) { + String welcome = getString(R.string.welcome) + model.getDisplayName(); + // TODO : initiate successful logged in experience + Toast.makeText(getApplicationContext(), welcome, Toast.LENGTH_LONG).show(); + } + + private void showLoginFailed(@StringRes Integer errorString) { + Toast.makeText(getApplicationContext(), errorString, Toast.LENGTH_SHORT).show(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/firefly/ui/login/LoginFormState.java b/src/main/java/com/example/firefly/ui/login/LoginFormState.java new file mode 100644 index 0000000..1772616 --- /dev/null +++ b/src/main/java/com/example/firefly/ui/login/LoginFormState.java @@ -0,0 +1,40 @@ +package com.example.firefly.ui.login; + +import androidx.annotation.Nullable; + +/** + * Data validation state of the login form. + */ +class LoginFormState { + @Nullable + private Integer usernameError; + @Nullable + private Integer passwordError; + private boolean isDataValid; + + LoginFormState(@Nullable Integer usernameError, @Nullable Integer passwordError) { + this.usernameError = usernameError; + this.passwordError = passwordError; + this.isDataValid = false; + } + + LoginFormState(boolean isDataValid) { + this.usernameError = null; + this.passwordError = null; + this.isDataValid = isDataValid; + } + + @Nullable + Integer getUsernameError() { + return usernameError; + } + + @Nullable + Integer getPasswordError() { + return passwordError; + } + + boolean isDataValid() { + return isDataValid; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/firefly/ui/login/LoginResult.java b/src/main/java/com/example/firefly/ui/login/LoginResult.java new file mode 100644 index 0000000..b6b2e35 --- /dev/null +++ b/src/main/java/com/example/firefly/ui/login/LoginResult.java @@ -0,0 +1,31 @@ +package com.example.firefly.ui.login; + +import androidx.annotation.Nullable; + +/** + * Authentication result : success (user details) or error message. + */ +class LoginResult { + @Nullable + private LoggedInUserView success; + @Nullable + private Integer error; + + LoginResult(@Nullable Integer error) { + this.error = error; + } + + LoginResult(@Nullable LoggedInUserView success) { + this.success = success; + } + + @Nullable + LoggedInUserView getSuccess() { + return success; + } + + @Nullable + Integer getError() { + return error; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/firefly/ui/login/LoginViewModel.java b/src/main/java/com/example/firefly/ui/login/LoginViewModel.java new file mode 100644 index 0000000..cfa06f8 --- /dev/null +++ b/src/main/java/com/example/firefly/ui/login/LoginViewModel.java @@ -0,0 +1,70 @@ +package com.example.firefly.ui.login; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import android.util.Patterns; + +import com.example.firefly.data.LoginRepository; +import com.example.firefly.data.Result; +import com.example.firefly.data.model.LoggedInUser; +import com.example.firefly.R; + +public class LoginViewModel extends ViewModel { + + private MutableLiveData loginFormState = new MutableLiveData<>(); + private MutableLiveData loginResult = new MutableLiveData<>(); + private LoginRepository loginRepository; + + LoginViewModel(LoginRepository loginRepository) { + this.loginRepository = loginRepository; + } + + LiveData getLoginFormState() { + return loginFormState; + } + + LiveData getLoginResult() { + return loginResult; + } + + public void login(String username, String password) { + // can be launched in a separate asynchronous job + Result result = loginRepository.login(username, password); + + if (result instanceof Result.Success) { + LoggedInUser data = ((Result.Success) result).getData(); + loginResult.setValue(new LoginResult(new LoggedInUserView(data.getDisplayName()))); + } else { + loginResult.setValue(new LoginResult(R.string.login_failed)); + } + } + + public void loginDataChanged(String username, String password) { + if (!isUserNameValid(username)) { + loginFormState.setValue(new LoginFormState(R.string.invalid_username, null)); + } else if (!isPasswordValid(password)) { + loginFormState.setValue(new LoginFormState(null, R.string.invalid_password)); + } else { + loginFormState.setValue(new LoginFormState(true)); + } + } + + // A placeholder username validation check + private boolean isUserNameValid(String username) { + if (username == null) { + return false; + } + if (username.contains("@")) { + return Patterns.EMAIL_ADDRESS.matcher(username).matches(); + } else { + return !username.trim().isEmpty(); + } + } + + // A placeholder password validation check + private boolean isPasswordValid(String password) { + return password != null && password.trim().length() > 5; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/firefly/ui/login/LoginViewModelFactory.java b/src/main/java/com/example/firefly/ui/login/LoginViewModelFactory.java new file mode 100644 index 0000000..84bbc7b --- /dev/null +++ b/src/main/java/com/example/firefly/ui/login/LoginViewModelFactory.java @@ -0,0 +1,26 @@ +package com.example.firefly.ui.login; + +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; +import androidx.annotation.NonNull; + +import com.example.firefly.data.LoginDataSource; +import com.example.firefly.data.LoginRepository; + +/** + * ViewModel provider factory to instantiate LoginViewModel. + * Required given LoginViewModel has a non-empty constructor + */ +public class LoginViewModelFactory implements ViewModelProvider.Factory { + + @NonNull + @Override + @SuppressWarnings("unchecked") + public T create(@NonNull Class modelClass) { + if (modelClass.isAssignableFrom(LoginViewModel.class)) { + return (T) new LoginViewModel(LoginRepository.getInstance(new LoginDataSource())); + } else { + throw new IllegalArgumentException("Unknown ViewModel class"); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/firefly/ui/settings/SettingsActivity.java b/src/main/java/com/example/firefly/ui/settings/SettingsActivity.java new file mode 100644 index 0000000..f9ef0f6 --- /dev/null +++ b/src/main/java/com/example/firefly/ui/settings/SettingsActivity.java @@ -0,0 +1,31 @@ +package com.example.firefly.ui.settings; + +import android.os.Bundle; + +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.preference.PreferenceFragmentCompat; + +public class SettingsActivity extends AppCompatActivity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.settings_activity); + getSupportFragmentManager() + .beginTransaction() + .replace(R.id.settings, new SettingsFragment()) + .commit(); + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true); + } + } + + public static class SettingsFragment extends PreferenceFragmentCompat { + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + setPreferencesFromResource(R.xml.root_preferences, rootKey); + } + } +} \ No newline at end of file diff --git a/src/main/res/drawable-v24/ic_launcher_foreground.xml b/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/res/drawable/ic_launcher_background.xml b/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/res/layout/activity_ip_connect.xml b/src/main/res/layout/activity_ip_connect.xml new file mode 100644 index 0000000..bee9201 --- /dev/null +++ b/src/main/res/layout/activity_ip_connect.xml @@ -0,0 +1,124 @@ + + + + + + + + +