Compare commits
10 commits
01182a0eee
...
9961a31936
| Author | SHA1 | Date | |
|---|---|---|---|
| 9961a31936 | |||
| 5e4560e89e | |||
| 3490cd6ed9 | |||
|
|
f810a0f645 | ||
|
|
5a277dd869 | ||
|
|
0df2b02ad5 | ||
|
|
4ed7f5d54a | ||
|
|
66586f83b9 | ||
|
|
5ab4301aca | ||
|
|
56a420bb7a |
5
.gitignore
vendored
|
|
@ -6,3 +6,8 @@
|
|||
/captures
|
||||
.externalNativeBuild
|
||||
.idea/*
|
||||
|
||||
|
||||
# Added by cargo
|
||||
|
||||
/target
|
||||
|
|
|
|||
2068
Cargo.lock
generated
Normal file
11
Cargo.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[package]
|
||||
name = "next-companion"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
gtk = { package = "gtk4", version = "0.10" }
|
||||
adw = { package = "libadwaita", version = "0.8", features = ["v1_6"] }
|
||||
reqwest = { version = "0.12", features = ["blocking", "rustls-tls"], default-features = false }
|
||||
serde_json = "1"
|
||||
dirs = "5"
|
||||
1
app/.gitignore
vendored
|
|
@ -1 +0,0 @@
|
|||
/build
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
apply plugin: 'com.android.application'
|
||||
|
||||
android {
|
||||
compileSdkVersion 28
|
||||
defaultConfig {
|
||||
applicationId "com.example.hochi.nextcompanion"
|
||||
minSdkVersion 15
|
||||
targetSdkVersion 28
|
||||
versionCode 8
|
||||
versionName "0.1.7.1"
|
||||
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation 'com.android.support:appcompat-v7:28.0.0'
|
||||
implementation 'com.android.support.constraint:constraint-layout:1.1.3'
|
||||
implementation 'com.android.support:design:28.0.0'
|
||||
testImplementation 'junit:junit:4.12'
|
||||
androidTestImplementation 'com.android.support.test:runner:1.0.2'
|
||||
androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
|
||||
}
|
||||
21
app/proguard-rules.pro
vendored
|
|
@ -1,21 +0,0 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
package com.example.hochi.nextcompanion;
|
||||
|
||||
import android.content.Context;
|
||||
import android.support.test.InstrumentationRegistry;
|
||||
import android.support.test.runner.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 <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ExampleInstrumentedTest {
|
||||
@Test
|
||||
public void useAppContext() {
|
||||
// Context of the app under test.
|
||||
Context appContext = InstrumentationRegistry.getTargetContext();
|
||||
|
||||
assertEquals("com.example.hochi.nextcompanion", appContext.getPackageName());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.example.hochi.nextcompanion">
|
||||
|
||||
<!-- To auto-complete the email text field in the login form with the user's emails -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/AppTheme.NoActionBar">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".LoginActivity"
|
||||
android:label="@string/title_activity_login" />
|
||||
<activity android:name=".RentActivity"
|
||||
android:label="@string/title_activity_rent">
|
||||
<meta-data
|
||||
android:name="android.support.PARENT_ACTIVITY"
|
||||
android:value=".MainActivity" />
|
||||
</activity>
|
||||
<activity android:name=".ReturnActivity"
|
||||
android:label="@string/title_activity_return">
|
||||
<meta-data
|
||||
android:name="android.support.PARENT_ACTIVITY"
|
||||
android:value=".MainActivity" />
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
package com.example.hochi.nextcompanion;
|
||||
|
||||
interface AsyncTaskCallbacks<T> {
|
||||
void onTaskComplete(T response);
|
||||
}
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
package com.example.hochi.nextcompanion;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.animation.AnimatorListenerAdapter;
|
||||
import android.annotation.TargetApi;
|
||||
|
||||
import android.content.SharedPreferences;
|
||||
import android.support.v7.app.AppCompatActivity;
|
||||
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.View;
|
||||
import android.view.View.OnClickListener;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/**
|
||||
* A login screen that offers login via phone number/pin.
|
||||
*/
|
||||
public class LoginActivity extends AppCompatActivity implements AsyncTaskCallbacks<String> {
|
||||
|
||||
/**
|
||||
* Keep track of the login task to ensure we can cancel it if requested.
|
||||
*/
|
||||
private RequestHandler mAuthTask = null;
|
||||
|
||||
// UI references.
|
||||
private TextView mPhoneView;
|
||||
private EditText mPinView;
|
||||
private View mProgressView;
|
||||
private View mLoginFormView;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_login);
|
||||
// Set up the login form.
|
||||
mPhoneView = findViewById(R.id.phone);
|
||||
|
||||
mPinView = findViewById(R.id.pin);
|
||||
mPinView.setOnEditorActionListener(new TextView.OnEditorActionListener() {
|
||||
@Override
|
||||
public boolean onEditorAction(TextView textView, int id, KeyEvent keyEvent) {
|
||||
if (id == EditorInfo.IME_ACTION_DONE || id == EditorInfo.IME_NULL) {
|
||||
attemptLogin();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
Button mPhoneSignInButton = findViewById(R.id.phone_sign_in_button);
|
||||
mPhoneSignInButton.setOnClickListener(new OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View view) {
|
||||
attemptLogin();
|
||||
}
|
||||
});
|
||||
|
||||
mLoginFormView = findViewById(R.id.login_form);
|
||||
mProgressView = findViewById(R.id.login_progress);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to sign in or register the account specified by the login form.
|
||||
* If there are form errors (invalid phone number, missing fields, etc.), the
|
||||
* errors are presented and no actual login attempt is made.
|
||||
*/
|
||||
private void attemptLogin() {
|
||||
if (mAuthTask != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset errors.
|
||||
mPhoneView.setError(null);
|
||||
mPinView.setError(null);
|
||||
|
||||
// Store values at the time of the login attempt.
|
||||
String phone = mPhoneView.getText().toString();
|
||||
String pin = mPinView.getText().toString();
|
||||
String[] credentials = {
|
||||
"apikey=", getString(R.string.apikey),
|
||||
"mobile=", mPhoneView.getText().toString(),
|
||||
"pin=", mPinView.getText().toString()
|
||||
};
|
||||
|
||||
boolean cancel = false;
|
||||
View focusView = null;
|
||||
|
||||
// Check for a valid pin, if the user entered one.
|
||||
if (TextUtils.isEmpty(pin)) {
|
||||
mPinView.setError(getString(R.string.error_field_required));
|
||||
focusView = mPinView;
|
||||
cancel = true;
|
||||
}
|
||||
|
||||
// Check for a valid phone address.
|
||||
if (TextUtils.isEmpty(phone)) {
|
||||
mPhoneView.setError(getString(R.string.error_field_required));
|
||||
focusView = mPhoneView;
|
||||
cancel = true;
|
||||
}
|
||||
|
||||
if (cancel) {
|
||||
// There was an error; don't attempt login and focus the first
|
||||
// form field with an error.
|
||||
focusView.requestFocus();
|
||||
} else {
|
||||
// Show a progress spinner, and kick off a background task to
|
||||
// perform the user login attempt.
|
||||
showProgress(true);
|
||||
mAuthTask = new RequestHandler(this, "POST",
|
||||
"api/login.json", credentials);
|
||||
mAuthTask.execute((Void) null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the progress UI and hides the login form.
|
||||
*/
|
||||
@TargetApi(Build.VERSION_CODES.HONEYCOMB_MR2)
|
||||
private void showProgress(final boolean show) {
|
||||
// On Honeycomb MR2 we have the ViewPropertyAnimator APIs, which allow
|
||||
// for very easy animations. If available, use these APIs to fade-in
|
||||
// the progress spinner.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB_MR2) {
|
||||
int shortAnimTime = getResources().getInteger(android.R.integer.config_shortAnimTime);
|
||||
|
||||
mLoginFormView.setVisibility(show ? View.GONE : View.VISIBLE);
|
||||
mLoginFormView.animate().setDuration(shortAnimTime).alpha(
|
||||
show ? 0 : 1).setListener(new AnimatorListenerAdapter() {
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation) {
|
||||
mLoginFormView.setVisibility(show ? View.GONE : View.VISIBLE);
|
||||
}
|
||||
});
|
||||
|
||||
mProgressView.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||
mProgressView.animate().setDuration(shortAnimTime).alpha(
|
||||
show ? 1 : 0).setListener(new AnimatorListenerAdapter() {
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation) {
|
||||
mProgressView.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// The ViewPropertyAnimator APIs are not available, so simply show
|
||||
// and hide the relevant UI components.
|
||||
mProgressView.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||
mLoginFormView.setVisibility(show ? View.GONE : View.VISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTaskComplete(String response) {
|
||||
//Callback called when RequestHandler finished request
|
||||
if (!response.isEmpty()) {
|
||||
try {
|
||||
JSONObject jObject = new JSONObject(response);
|
||||
JSONObject userObject = jObject.getJSONObject("user");
|
||||
String loginkey = userObject.getString("loginkey");
|
||||
SharedPreferences sharedPref = getSharedPreferences("persistence", MODE_PRIVATE);
|
||||
SharedPreferences.Editor editor = sharedPref.edit();
|
||||
editor.putString("loginKey", loginkey);
|
||||
editor.apply();
|
||||
}
|
||||
catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
finish();
|
||||
} else {
|
||||
mPinView.setError(getString(R.string.error_incorrect_pin));
|
||||
mPinView.requestFocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
package com.example.hochi.nextcompanion;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.support.design.widget.FloatingActionButton;
|
||||
import android.support.v7.app.AppCompatActivity;
|
||||
import android.support.v7.widget.Toolbar;
|
||||
import android.view.View;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.widget.AdapterView;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.ListView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
public class MainActivity extends AppCompatActivity implements AsyncTaskCallbacks<String> {
|
||||
private RequestHandler getBikesTask = null;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
//now this "every android activity" stuff
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_main);
|
||||
Toolbar toolbar = findViewById(R.id.toolbar);
|
||||
setSupportActionBar(toolbar);
|
||||
final Context context = this;
|
||||
|
||||
//Floating Button
|
||||
FloatingActionButton fab = findViewById(R.id.fab);
|
||||
fab.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View view) {
|
||||
Intent intent = new Intent(context, RentActivity.class);
|
||||
startActivity(intent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
super.onStart();
|
||||
//pre-condition: Is there a login key?
|
||||
SharedPreferences sharedPref = getSharedPreferences("persistence", MODE_PRIVATE);
|
||||
String defaultValue = "nokey";
|
||||
String loginKey = sharedPref.getString("loginKey", defaultValue);
|
||||
//if not, go to LoginActivity
|
||||
if (loginKey.equals("nokey")) {
|
||||
Intent intent = new Intent(this, LoginActivity.class);
|
||||
startActivity(intent);
|
||||
}
|
||||
else {
|
||||
reloadBikeList();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu menu) {
|
||||
// Inflate the menu; this adds items to the action bar if it is present.
|
||||
getMenuInflater().inflate(R.menu.menu_main, menu);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
// Handle action bar item clicks here. The action bar will
|
||||
// automatically handle clicks on the Home/Up button, so long
|
||||
// as you specify a parent activity in AndroidManifest.xml.
|
||||
int id = item.getItemId();
|
||||
|
||||
|
||||
//noinspection SimplifiableIfStatement
|
||||
if (id == R.id.action_logout) {
|
||||
SharedPreferences sharedPref = getSharedPreferences("persistence", MODE_PRIVATE);
|
||||
SharedPreferences.Editor editor = sharedPref.edit();
|
||||
editor.remove("loginkey");
|
||||
editor.apply();
|
||||
Intent intent = new Intent(this, LoginActivity.class);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
if (id == R.id.action_map) {
|
||||
Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.map_url)));
|
||||
startActivity(browserIntent);
|
||||
}
|
||||
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
|
||||
protected void reloadBikeList() {
|
||||
//get loginkey
|
||||
SharedPreferences sharedPref = getSharedPreferences("persistence", MODE_PRIVATE);
|
||||
String defaultValue = "nokey";
|
||||
String loginKey = sharedPref.getString("loginKey", defaultValue);
|
||||
|
||||
String[] params = {
|
||||
"apikey=", getString(R.string.apikey),
|
||||
"loginkey=", loginKey
|
||||
};
|
||||
|
||||
getBikesTask = new RequestHandler(this, "POST",
|
||||
"api/getOpenRentals.json", params);
|
||||
getBikesTask.execute((Void) null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTaskComplete(String response) {
|
||||
//Callback called when RequestHandler finished request
|
||||
final Context context = this;
|
||||
if (!response.isEmpty()) {
|
||||
final ArrayList<String> list = new ArrayList<>();
|
||||
try {
|
||||
JSONObject jObject = new JSONObject(response);
|
||||
JSONArray bikesArray = jObject.getJSONArray("rentalCollection");
|
||||
for (int i = 0; i < bikesArray.length(); i++) {
|
||||
String entry;
|
||||
JSONObject bike = bikesArray.getJSONObject(i);
|
||||
entry = "Bike " + bike.getString("bike")
|
||||
+ " with lock code " + bike.getString("code");
|
||||
list.add(entry);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
//Create and fill list
|
||||
final ListView listview = findViewById(R.id.listview);
|
||||
final ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
|
||||
android.R.layout.simple_list_item_1, list);
|
||||
listview.setAdapter(adapter);
|
||||
|
||||
//Print indicator if empty
|
||||
TextView tv = findViewById(R.id.noBikes);
|
||||
if(list.isEmpty()) tv.setVisibility(View.VISIBLE);
|
||||
else tv.setVisibility(View.INVISIBLE);
|
||||
|
||||
try {
|
||||
final JSONObject jObject = new JSONObject(response);
|
||||
final JSONArray bikesArray = jObject.getJSONArray("rentalCollection");
|
||||
listview.setOnItemClickListener(new AdapterView.OnItemClickListener() {
|
||||
@Override
|
||||
public void onItemClick(AdapterView<?> parent, final View view, int position, long id) {
|
||||
Intent intent = new Intent(context, ReturnActivity.class);
|
||||
try {
|
||||
JSONObject bike = bikesArray.getJSONObject(position);
|
||||
String bID = bike.getString("bike");
|
||||
String stID = bike.getString("start_place");
|
||||
String lockE = bike.getString("electric_lock");
|
||||
String[] bikeArray = {bID, stID, lockE};
|
||||
intent.putExtra("bike", bikeArray);
|
||||
startActivity(intent);
|
||||
}
|
||||
catch (JSONException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
} catch (JSONException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
else {
|
||||
//TODO: implement error handling
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
package com.example.hochi.nextcompanion;
|
||||
|
||||
import android.content.SharedPreferences;
|
||||
import android.support.v7.app.AppCompatActivity;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
import android.widget.TextView;
|
||||
|
||||
public class RentActivity extends AppCompatActivity implements AsyncTaskCallbacks<String> {
|
||||
private RequestHandler rentRequestTask = null;
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_rent);
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
Button mRentSubmitButton = findViewById(R.id.rent_submit_button);
|
||||
mRentSubmitButton.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View view) {
|
||||
rentRequest();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void rentRequest() {
|
||||
//Prepare request to rent bike
|
||||
TextView mBikeInput;
|
||||
mBikeInput = findViewById(R.id.bike_id);
|
||||
String bikeID = mBikeInput.getText().toString();
|
||||
//get loginkey
|
||||
SharedPreferences sharedPref = getSharedPreferences("persistence", MODE_PRIVATE);
|
||||
String defaultValue = "nokey";
|
||||
String loginKey = sharedPref.getString("loginKey", defaultValue);
|
||||
|
||||
String[] params = {
|
||||
"apikey=", getString(R.string.apikey),
|
||||
"loginkey=", loginKey,
|
||||
"bike=", bikeID
|
||||
};
|
||||
|
||||
rentRequestTask = new RequestHandler(this, "POST",
|
||||
"api/rent.json", params);
|
||||
rentRequestTask.execute((Void) null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTaskComplete(String response) {
|
||||
//get back to main activity
|
||||
//TODO: *any* response handling
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
package com.example.hochi.nextcompanion;
|
||||
|
||||
import android.os.AsyncTask;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.net.URLEncoder;
|
||||
|
||||
public class RequestHandler extends AsyncTask<Void, Void, String> {
|
||||
|
||||
private String mHTTPmethod;
|
||||
private String mEndpoint;
|
||||
private AsyncTaskCallbacks<String> callback;
|
||||
private String[] mCredentials;
|
||||
|
||||
RequestHandler(AsyncTaskCallbacks<String> act, String HTTPmethod,
|
||||
String endpoint, String[] credentials) {
|
||||
mHTTPmethod = HTTPmethod;
|
||||
mEndpoint = endpoint;
|
||||
mCredentials = credentials;
|
||||
callback = act;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String doInBackground(Void... params) {
|
||||
StringBuilder response = new StringBuilder();
|
||||
StringBuilder urlParameters = new StringBuilder();
|
||||
int i=0;
|
||||
while (i<mCredentials.length) {
|
||||
urlParameters.append("&").append(mCredentials[i])
|
||||
.append(URLEncoder.encode(mCredentials[i+1]));
|
||||
i=i+2;
|
||||
}
|
||||
|
||||
HttpURLConnection connection = null;
|
||||
try {
|
||||
|
||||
//Create connection
|
||||
URL url = new URL("https://api.nextbike.net/" + mEndpoint);
|
||||
connection = (HttpURLConnection) url.openConnection();
|
||||
connection.setRequestMethod(mHTTPmethod);
|
||||
if(mHTTPmethod.equals("POST")) {
|
||||
connection.setRequestProperty("Content-Type",
|
||||
"application/x-www-form-urlencoded");
|
||||
|
||||
connection.setRequestProperty("Content-Length", "" +
|
||||
Integer.toString(urlParameters.toString().getBytes().length));
|
||||
connection.setRequestProperty("Content-Language", "en-US");
|
||||
|
||||
connection.setUseCaches(false);
|
||||
connection.setDoInput(true);
|
||||
connection.setDoOutput(true);
|
||||
}
|
||||
|
||||
//Send request
|
||||
DataOutputStream wr = new DataOutputStream (
|
||||
connection.getOutputStream ());
|
||||
wr.writeBytes (urlParameters.toString());
|
||||
wr.flush ();
|
||||
wr.close ();
|
||||
|
||||
//Get Response
|
||||
InputStream is = connection.getInputStream();
|
||||
BufferedReader rd = new BufferedReader(new InputStreamReader(is));
|
||||
String line;
|
||||
while((line = rd.readLine()) != null) {
|
||||
response.append(line);
|
||||
response.append('\r');
|
||||
}
|
||||
rd.close();
|
||||
} catch (Exception e) {
|
||||
|
||||
e.printStackTrace();
|
||||
|
||||
} finally {
|
||||
if(connection != null) {
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
return response.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(final String response) {
|
||||
//TODO: reimplement progress or remove support for it
|
||||
callback.onTaskComplete(response);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCancelled() {
|
||||
//TODO: proper handling if needed
|
||||
}
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
package com.example.hochi.nextcompanion;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.support.v7.app.AppCompatActivity;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
public class ReturnActivity extends AppCompatActivity implements AsyncTaskCallbacks<String> {
|
||||
private String[] bikeArray;
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_return);
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
|
||||
|
||||
Intent intent = getIntent();
|
||||
bikeArray = intent.getStringArrayExtra("bike");
|
||||
|
||||
//if GPS and electric lock, show the instruction
|
||||
TextView tv = findViewById(R.id.gps_info);
|
||||
LinearLayout la = findViewById(R.id.return_form_container);
|
||||
if(bikeArray[2].equals("true")) {
|
||||
tv.setVisibility(View.VISIBLE);
|
||||
la.setVisibility(View.INVISIBLE);
|
||||
}
|
||||
else {
|
||||
la.setVisibility(View.VISIBLE);
|
||||
tv.setVisibility(View.INVISIBLE);
|
||||
Button mReturnSubmitButton = findViewById(R.id.return_submit_button);
|
||||
mReturnSubmitButton.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View view) {
|
||||
returnRequest();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
void returnRequest() {
|
||||
TextView mStationInput;
|
||||
mStationInput = findViewById(R.id.return_station_id);
|
||||
String stationID = mStationInput.getText().toString();
|
||||
//get loginkey
|
||||
SharedPreferences sharedPref = getSharedPreferences("persistence", MODE_PRIVATE);
|
||||
String defaultValue = "nokey";
|
||||
String loginKey = sharedPref.getString("loginKey", defaultValue);
|
||||
|
||||
String[] params = {
|
||||
"apikey=", getString(R.string.apikey),
|
||||
"bike=", bikeArray[0],
|
||||
"loginkey=", loginKey,
|
||||
"station=", stationID,
|
||||
"comment=", ""
|
||||
};
|
||||
RequestHandler returnRequestTask = new RequestHandler(this, "POST",
|
||||
"api/return.json", params);
|
||||
returnRequestTask.execute((Void) null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTaskComplete(String response) {
|
||||
//get back to main activity
|
||||
//TODO: *any* response handling
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#ffffff"
|
||||
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
|
||||
</vector>
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:paddingBottom="@dimen/activity_vertical_margin"
|
||||
android:paddingLeft="@dimen/activity_horizontal_margin"
|
||||
android:paddingRight="@dimen/activity_horizontal_margin"
|
||||
android:paddingTop="@dimen/activity_vertical_margin"
|
||||
tools:context=".LoginActivity">
|
||||
|
||||
<!-- Login progress -->
|
||||
<ProgressBar
|
||||
android:id="@+id/login_progress"
|
||||
style="?android:attr/progressBarStyleLarge"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/login_form"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/phone_login_form"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<android.support.design.widget.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/phone"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/prompt_phone"
|
||||
android:inputType="phone"
|
||||
android:maxLines="1"
|
||||
android:singleLine="true" />
|
||||
|
||||
</android.support.design.widget.TextInputLayout>
|
||||
|
||||
<android.support.design.widget.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/pin"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/prompt_pin"
|
||||
android:imeActionId="6"
|
||||
android:imeActionLabel="@string/action_sign_in_short"
|
||||
android:imeOptions="actionUnspecified"
|
||||
android:inputType="numberPassword"
|
||||
android:maxLines="1"
|
||||
android:singleLine="true" />
|
||||
|
||||
</android.support.design.widget.TextInputLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/phone_sign_in_button"
|
||||
style="?android:textAppearanceSmall"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/action_sign_in"
|
||||
android:textStyle="bold" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<android.support.design.widget.AppBarLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:theme="@style/AppTheme.AppBarOverlay">
|
||||
|
||||
<android.support.v7.widget.Toolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:background="?attr/colorPrimary"
|
||||
app:popupTheme="@style/AppTheme.PopupOverlay" />
|
||||
|
||||
</android.support.design.widget.AppBarLayout>
|
||||
|
||||
<include layout="@layout/content_main" />
|
||||
|
||||
<android.support.design.widget.FloatingActionButton
|
||||
android:id="@+id/fab"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_margin="@dimen/fab_margin"
|
||||
android:src="@drawable/ic_add_white_24dp" />
|
||||
|
||||
</android.support.design.widget.CoordinatorLayout>
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:paddingBottom="@dimen/activity_vertical_margin"
|
||||
android:paddingLeft="@dimen/activity_horizontal_margin"
|
||||
android:paddingRight="@dimen/activity_horizontal_margin"
|
||||
android:paddingTop="@dimen/activity_vertical_margin"
|
||||
tools:context=".RentActivity">
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/rent_form"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<android.support.design.widget.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/bike_id"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/prompt_bike_id"
|
||||
android:inputType="number"
|
||||
android:maxLines="1"
|
||||
android:singleLine="true" />
|
||||
|
||||
</android.support.design.widget.TextInputLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/rent_submit_button"
|
||||
style="?android:textAppearanceSmall"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/action_rent_submit"
|
||||
android:textStyle="bold" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</android.support.constraint.ConstraintLayout>
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:paddingBottom="@dimen/activity_vertical_margin"
|
||||
android:paddingLeft="@dimen/activity_horizontal_margin"
|
||||
android:paddingRight="@dimen/activity_horizontal_margin"
|
||||
android:paddingTop="@dimen/activity_vertical_margin"
|
||||
tools:context=".ReturnActivity">
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/return_form"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
<LinearLayout
|
||||
android:id="@+id/return_form_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
<android.support.design.widget.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/return_station_id"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/prompt_return_station_id"
|
||||
android:inputType="number"
|
||||
android:maxLines="1"
|
||||
android:singleLine="true" />
|
||||
|
||||
</android.support.design.widget.TextInputLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/return_submit_button"
|
||||
style="?android:textAppearanceSmall"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/action_return_submit"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/gps_info"
|
||||
android:gravity="center"
|
||||
android:textSize="20sp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:text="@string/indicator_electronic_lock" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</android.support.constraint.ConstraintLayout>
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior"
|
||||
tools:context=".MainActivity"
|
||||
tools:showIn="@layout/activity_main">
|
||||
<TextView
|
||||
android:id="@+id/noBikes"
|
||||
android:gravity="center"
|
||||
android:textSize="20sp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:text="@string/indicator_no_bikes" />
|
||||
|
||||
<ListView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/listview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
|
||||
</android.support.constraint.ConstraintLayout>
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:context="com.example.hochi.nextcompanion.MainActivity">
|
||||
<item
|
||||
android:id="@+id/action_map"
|
||||
android:orderInCategory="100"
|
||||
android:title="@string/action_map"
|
||||
app:showAsAction="never" />
|
||||
<item
|
||||
android:id="@+id/action_logout"
|
||||
android:orderInCategory="100"
|
||||
android:title="@string/action_logout"
|
||||
app:showAsAction="never" />
|
||||
</menu>
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 9.5 KiB |
|
|
@ -1,6 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="colorPrimary">#3F51B5</color>
|
||||
<color name="colorPrimaryDark">#303F9F</color>
|
||||
<color name="colorAccent">#9c274f</color>
|
||||
</resources>
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<resources>
|
||||
<dimen name="fab_margin">16dp</dimen>
|
||||
<!-- Default screen margins, per the Android Design guidelines. -->
|
||||
<dimen name="activity_horizontal_margin">16dp</dimen>
|
||||
<dimen name="activity_vertical_margin">16dp</dimen>
|
||||
</resources>
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#000000</color>
|
||||
</resources>
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
<resources>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.AppBarOverlay" parent="ThemeOverlay.AppCompat.Dark.ActionBar" />
|
||||
|
||||
<style name="AppTheme.PopupOverlay" parent="ThemeOverlay.AppCompat.Light" />
|
||||
|
||||
</resources>
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
package com.example.hochi.nextcompanion;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
public class ExampleUnitTest {
|
||||
@Test
|
||||
public void addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2);
|
||||
}
|
||||
}
|
||||
1472
build-aux/cargo-sources.json
Normal file
5
build-aux/cargo-vendor-config.toml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
[source.crates-io]
|
||||
replace-with = "vendored-sources"
|
||||
|
||||
[source.vendored-sources]
|
||||
directory = "/run/build/next-companion/cargo-vendor"
|
||||
55
build-aux/flatpak-cargo-generator.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Generate Flatpak source entries for Cargo dependencies from Cargo.lock.
|
||||
|
||||
Usage:
|
||||
python3 build-aux/flatpak-cargo-generator.py [Cargo.lock] \
|
||||
> build-aux/cargo-sources.json
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ImportError:
|
||||
try:
|
||||
import tomli as tomllib # pip install tomli
|
||||
except ImportError:
|
||||
print("Error: requires Python 3.11+ or the 'tomli' package", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
CRATES_IO_DL = "https://static.crates.io/crates"
|
||||
REGISTRY_SOURCE = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
lockfile = sys.argv[1] if len(sys.argv) > 1 else "Cargo.lock"
|
||||
|
||||
with open(lockfile, "rb") as f:
|
||||
lock = tomllib.load(f)
|
||||
|
||||
sources = []
|
||||
|
||||
for pkg in lock.get("package", []):
|
||||
name = pkg["name"]
|
||||
version = pkg["version"]
|
||||
source = pkg.get("source", "")
|
||||
checksum = pkg.get("checksum")
|
||||
|
||||
# Only vendor packages from crates.io (they have a checksum)
|
||||
if source == REGISTRY_SOURCE and checksum:
|
||||
sources.append(
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gz",
|
||||
"url": f"{CRATES_IO_DL}/{name}/{version}/download",
|
||||
"sha256": checksum,
|
||||
"dest": f"cargo-vendor/{name}-{version}",
|
||||
}
|
||||
)
|
||||
|
||||
print(json.dumps(sources, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
27
build.gradle
|
|
@ -1,27 +0,0 @@
|
|||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
|
||||
repositories {
|
||||
google()
|
||||
jcenter()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:3.3.1'
|
||||
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
jcenter()
|
||||
}
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
62
flake.lock
generated
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1772963539,
|
||||
"narHash": "sha256-9jVDGZnvCckTGdYT53d/EfznygLskyLQXYwJLKMPsZs=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "9dcb002ca1690658be4a04645215baea8b95f31d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1744536153,
|
||||
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1773115373,
|
||||
"narHash": "sha256-bfK9FJFcQth6f3ydYggS5m0z2NRGF/PY6Y2XgZDJ6pg=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "1924b4672a2b8e4aee6e6652ec2e59a8d3c5648e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
51
flake.nix
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
{
|
||||
description = "NextCompanion — a minimal GTK4 Nextbike client for Linux";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, rust-overlay, ... }:
|
||||
let
|
||||
system = "x86_64-linux";
|
||||
overlays = [ (import rust-overlay) ];
|
||||
pkgs = import nixpkgs { inherit system overlays; };
|
||||
|
||||
runtimeDeps = with pkgs; [
|
||||
gtk4
|
||||
libadwaita
|
||||
glib
|
||||
];
|
||||
buildDeps = with pkgs; [
|
||||
pkg-config
|
||||
rust-bin.stable.latest.default
|
||||
];
|
||||
in
|
||||
{
|
||||
devShells.${system}.default = pkgs.mkShell {
|
||||
buildInputs = buildDeps ++ runtimeDeps;
|
||||
shellHook = ''
|
||||
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath runtimeDeps}:$LD_LIBRARY_PATH"
|
||||
'';
|
||||
};
|
||||
|
||||
packages.${system}.default = pkgs.rustPlatform.buildRustPackage {
|
||||
pname = "next-companion";
|
||||
version = "0.1.0";
|
||||
src = ./.;
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
nativeBuildInputs = buildDeps;
|
||||
buildInputs = runtimeDeps;
|
||||
postInstall = ''
|
||||
install -Dm644 data/icons/org.nextbike.NextCompanion.png \
|
||||
$out/share/icons/hicolor/512x512/apps/org.nextbike.NextCompanion.png
|
||||
'';
|
||||
};
|
||||
|
||||
apps.${system}.default = {
|
||||
type = "app";
|
||||
program = "${self.packages.${system}.default}/bin/next-companion";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
# Project-wide Gradle settings.
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx1536m
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
6
gradle/wrapper/gradle-wrapper.properties
vendored
|
|
@ -1,6 +0,0 @@
|
|||
#Fri May 03 12:47:19 GMT 2019
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.1-all.zip
|
||||
172
gradlew
vendored
|
|
@ -1,172 +0,0 @@
|
|||
#!/usr/bin/env sh
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS=""
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin, switch paths to Windows format before running java
|
||||
if $cygwin ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=$((i+1))
|
||||
done
|
||||
case $i in
|
||||
(0) set -- ;;
|
||||
(1) set -- "$args0" ;;
|
||||
(2) set -- "$args0" "$args1" ;;
|
||||
(3) set -- "$args0" "$args1" "$args2" ;;
|
||||
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=$(save "$@")
|
||||
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
|
||||
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
|
||||
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
|
||||
cd "$(dirname "$0")"
|
||||
fi
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
84
gradlew.bat
vendored
|
|
@ -1,84 +0,0 @@
|
|||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS=
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:init
|
||||
@rem Get command-line arguments, handling Windows variants
|
||||
|
||||
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||
|
||||
:win9xME_args
|
||||
@rem Slurp the command line arguments.
|
||||
set CMD_LINE_ARGS=
|
||||
set _SKIP=2
|
||||
|
||||
:win9xME_args_slurp
|
||||
if "x%~1" == "x" goto execute
|
||||
|
||||
set CMD_LINE_ARGS=%*
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
47
org.nextbike.NextCompanion.yml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
app-id: org.nextbike.NextCompanion
|
||||
runtime: org.gnome.Platform
|
||||
runtime-version: '47'
|
||||
sdk: org.gnome.Sdk
|
||||
sdk-extensions:
|
||||
- org.freedesktop.Sdk.Extension.rust-stable
|
||||
command: next-companion
|
||||
|
||||
finish-args:
|
||||
- --share=network # nextbike API calls
|
||||
- --share=ipc
|
||||
- --socket=wayland
|
||||
- --socket=fallback-x11
|
||||
- --device=dri # GPU acceleration
|
||||
|
||||
build-options:
|
||||
append-path: /usr/lib/sdk/rust-stable/bin
|
||||
env:
|
||||
CARGO_HOME: /run/build/next-companion/cargo-home
|
||||
RUST_BACKTRACE: '1'
|
||||
arch:
|
||||
aarch64:
|
||||
env:
|
||||
CARGO_BUILD_TARGET: aarch64-unknown-linux-gnu
|
||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-unknown-linux-gnu-gcc
|
||||
|
||||
modules:
|
||||
- name: next-companion
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
- mkdir -p .cargo
|
||||
- cp cargo-vendor-config/cargo-vendor-config.toml .cargo/config.toml
|
||||
- cargo --offline build --release
|
||||
- |
|
||||
install -Dm755 \
|
||||
"target/${CARGO_BUILD_TARGET:+${CARGO_BUILD_TARGET}/}release/next-companion" \
|
||||
/app/bin/next-companion
|
||||
- install -Dm644 data/icons/org.nextbike.NextCompanion.png
|
||||
/app/share/icons/hicolor/512x512/apps/org.nextbike.NextCompanion.png
|
||||
sources:
|
||||
- type: dir
|
||||
path: .
|
||||
- type: file
|
||||
path: build-aux/cargo-vendor-config.toml
|
||||
dest: cargo-vendor-config
|
||||
dest-filename: cargo-vendor-config.toml
|
||||
- build-aux/cargo-sources.json
|
||||
1
result
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/nix/store/gan72x7dji7c6lwngwfx88my14azg9wn-next-companion-0.1.0
|
||||
|
|
@ -1 +0,0 @@
|
|||
include ':app'
|
||||
608
src/main.rs
Normal file
|
|
@ -0,0 +1,608 @@
|
|||
use adw::prelude::*;
|
||||
use adw::{Application, ApplicationWindow, BottomSheet, Clamp, HeaderBar, NavigationPage, NavigationView};
|
||||
use gtk::{
|
||||
Box, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, Spinner, Stack,
|
||||
};
|
||||
use gtk::gio;
|
||||
use gtk::glib;
|
||||
use std::cell::RefCell;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
const API_KEY: &str = "3IaBlP9OZw14dvES";
|
||||
const BASE_URL: &str = "https://api.nextbike.net";
|
||||
|
||||
// ── Bike model ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Bike {
|
||||
id: String,
|
||||
code: String,
|
||||
electric_lock: bool,
|
||||
}
|
||||
|
||||
// ── Persistent login key ──────────────────────────────────────────────────────
|
||||
|
||||
fn config_path() -> PathBuf {
|
||||
let mut p = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||
p.push("next-companion");
|
||||
p.push("loginkey");
|
||||
p
|
||||
}
|
||||
|
||||
fn load_loginkey() -> Option<String> {
|
||||
fs::read_to_string(config_path())
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn save_loginkey(key: &str) {
|
||||
let path = config_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
let _ = fs::write(&path, key);
|
||||
}
|
||||
|
||||
fn clear_loginkey() {
|
||||
let _ = fs::remove_file(config_path());
|
||||
}
|
||||
|
||||
// ── API calls (blocking — run via gio::spawn_blocking) ────────────────────────
|
||||
|
||||
fn api_login(phone: &str, pin: &str) -> Result<String, String> {
|
||||
let resp = reqwest::blocking::Client::new()
|
||||
.post(format!("{BASE_URL}/api/login.json"))
|
||||
.form(&[("apikey", API_KEY), ("mobile", phone), ("pin", pin)])
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_str(&resp.text().map_err(|e| e.to_string())?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
json["user"]["loginkey"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "Invalid credentials".to_string())
|
||||
}
|
||||
|
||||
fn api_get_rentals(loginkey: &str) -> Result<Vec<Bike>, String> {
|
||||
let resp = reqwest::blocking::Client::new()
|
||||
.post(format!("{BASE_URL}/api/getOpenRentals.json"))
|
||||
.form(&[("apikey", API_KEY), ("loginkey", loginkey)])
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_str(&resp.text().map_err(|e| e.to_string())?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let arr = json["rentalCollection"]
|
||||
.as_array()
|
||||
.ok_or_else(|| "No rental data".to_string())?;
|
||||
Ok(arr
|
||||
.iter()
|
||||
.map(|b| Bike {
|
||||
id: b["bike"].as_str().unwrap_or("").to_string(),
|
||||
code: b["code"].as_str().unwrap_or("").to_string(),
|
||||
electric_lock: b["electric_lock"].as_str().map_or(false, |s| s == "true"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn api_rent(loginkey: &str, bike_id: &str) -> Result<(), String> {
|
||||
reqwest::blocking::Client::new()
|
||||
.post(format!("{BASE_URL}/api/rent.json"))
|
||||
.form(&[("apikey", API_KEY), ("loginkey", loginkey), ("bike", bike_id)])
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn api_return(loginkey: &str, bike_id: &str, station_id: &str) -> Result<(), String> {
|
||||
reqwest::blocking::Client::new()
|
||||
.post(format!("{BASE_URL}/api/return.json"))
|
||||
.form(&[
|
||||
("apikey", API_KEY),
|
||||
("bike", bike_id),
|
||||
("loginkey", loginkey),
|
||||
("station", station_id),
|
||||
("comment", ""),
|
||||
])
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Async helpers (run on GLib main context) ──────────────────────────────────
|
||||
|
||||
async fn load_rentals(key: String, bikes: Rc<RefCell<Vec<Bike>>>, bikes_list: ListBox, list_stack: Stack) {
|
||||
let result = match gio::spawn_blocking(move || api_get_rentals(&key)).await {
|
||||
Ok(r) => r,
|
||||
Err(_) => return,
|
||||
};
|
||||
if let Ok(new_bikes) = result {
|
||||
while let Some(child) = bikes_list.first_child() {
|
||||
bikes_list.remove(&child);
|
||||
}
|
||||
for bike in &new_bikes {
|
||||
let text = format!(
|
||||
"Bike {} · code: {}{}",
|
||||
bike.id,
|
||||
bike.code,
|
||||
if bike.electric_lock { " ⚡" } else { "" }
|
||||
);
|
||||
let lbl = Label::builder()
|
||||
.label(&text)
|
||||
.xalign(0.0)
|
||||
.margin_top(14)
|
||||
.margin_bottom(14)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.build();
|
||||
let row = ListBoxRow::new();
|
||||
row.set_child(Some(&lbl));
|
||||
bikes_list.append(&row);
|
||||
}
|
||||
list_stack.set_visible_child_name(if new_bikes.is_empty() { "empty" } else { "list" });
|
||||
*bikes.borrow_mut() = new_bikes;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn main() -> glib::ExitCode {
|
||||
let app = Application::builder()
|
||||
.application_id("org.nextbike.NextCompanion")
|
||||
.build();
|
||||
app.connect_activate(build_ui);
|
||||
app.run()
|
||||
}
|
||||
|
||||
// ── UI ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn build_ui(app: &Application) {
|
||||
let loginkey: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(load_loginkey()));
|
||||
let bikes: Rc<RefCell<Vec<Bike>>> = Rc::new(RefCell::new(vec![]));
|
||||
let return_bike: Rc<RefCell<Option<Bike>>> = Rc::new(RefCell::new(None));
|
||||
|
||||
// ── Login page ────────────────────────────────────────────────────────────
|
||||
let phone_entry = Entry::builder()
|
||||
.placeholder_text("Phone number")
|
||||
.build();
|
||||
let pin_entry = Entry::builder()
|
||||
.placeholder_text("PIN")
|
||||
.visibility(false)
|
||||
.build();
|
||||
let login_err = Label::builder()
|
||||
.css_classes(["error"])
|
||||
.wrap(true)
|
||||
.visible(false)
|
||||
.build();
|
||||
let login_btn = Button::builder()
|
||||
.label("Sign In")
|
||||
.css_classes(["suggested-action", "pill"])
|
||||
.build();
|
||||
let login_spinner = Spinner::new();
|
||||
|
||||
let login_form = Box::builder()
|
||||
.orientation(Orientation::Vertical)
|
||||
.spacing(12)
|
||||
.margin_top(24).margin_bottom(24).margin_start(12).margin_end(12)
|
||||
.build();
|
||||
login_form.append(&phone_entry);
|
||||
login_form.append(&pin_entry);
|
||||
login_form.append(&login_err);
|
||||
login_form.append(&login_btn);
|
||||
login_form.append(&login_spinner);
|
||||
|
||||
let login_clamp = Clamp::builder().maximum_size(400).build();
|
||||
login_clamp.set_child(Some(&login_form));
|
||||
|
||||
let login_body = Box::builder().orientation(Orientation::Vertical).build();
|
||||
login_body.append(&HeaderBar::new());
|
||||
login_body.append(&login_clamp);
|
||||
|
||||
let login_page = NavigationPage::builder()
|
||||
.title("Sign In")
|
||||
.child(&login_body)
|
||||
.build();
|
||||
|
||||
// ── Main page ─────────────────────────────────────────────────────────────
|
||||
let bikes_list = ListBox::builder()
|
||||
.css_classes(["boxed-list"])
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.margin_top(8).margin_bottom(8).margin_start(12).margin_end(12)
|
||||
.build();
|
||||
let empty_label = Label::builder()
|
||||
.label("No active rentals")
|
||||
.margin_top(48)
|
||||
.css_classes(["dim-label"])
|
||||
.build();
|
||||
let list_stack = Stack::new();
|
||||
list_stack.add_named(&empty_label, Some("empty"));
|
||||
list_stack.add_named(&bikes_list, Some("list"));
|
||||
list_stack.set_visible_child_name("empty");
|
||||
|
||||
let scroll = ScrolledWindow::builder().vexpand(true).child(&list_stack).build();
|
||||
|
||||
let rent_btn = Button::builder()
|
||||
.label("Rent a Bike")
|
||||
.css_classes(["suggested-action", "pill"])
|
||||
.margin_top(8).margin_bottom(12).margin_start(12).margin_end(12)
|
||||
.build();
|
||||
|
||||
let main_hdr = HeaderBar::new();
|
||||
let logout_btn = Button::builder()
|
||||
.icon_name("system-log-out-symbolic")
|
||||
.tooltip_text("Logout")
|
||||
.build();
|
||||
let refresh_btn = Button::builder()
|
||||
.icon_name("view-refresh-symbolic")
|
||||
.tooltip_text("Refresh")
|
||||
.build();
|
||||
main_hdr.pack_end(&logout_btn);
|
||||
main_hdr.pack_start(&refresh_btn);
|
||||
|
||||
// ── Bottom sheet (rent + return) ──────────────────────────────────────────
|
||||
|
||||
// — Rent form —
|
||||
let bike_entry = Entry::builder()
|
||||
.placeholder_text("Bike number")
|
||||
.input_purpose(gtk::InputPurpose::Digits)
|
||||
.build();
|
||||
let rent_err = Label::builder()
|
||||
.css_classes(["error"])
|
||||
.wrap(true)
|
||||
.visible(false)
|
||||
.build();
|
||||
let rent_submit = Button::builder()
|
||||
.label("Rent")
|
||||
.css_classes(["suggested-action", "pill"])
|
||||
.build();
|
||||
let rent_spinner = Spinner::new();
|
||||
|
||||
let rent_sheet = Box::builder()
|
||||
.orientation(Orientation::Vertical)
|
||||
.spacing(12)
|
||||
.build();
|
||||
let rent_form = Box::builder()
|
||||
.orientation(Orientation::Vertical)
|
||||
.spacing(12)
|
||||
.build();
|
||||
rent_form.append(&bike_entry);
|
||||
rent_form.append(&rent_err);
|
||||
rent_form.append(&rent_submit);
|
||||
rent_form.append(&rent_spinner);
|
||||
rent_sheet.append(&rent_form);
|
||||
|
||||
// — Return form —
|
||||
let station_entry = Entry::builder()
|
||||
.placeholder_text("Station number")
|
||||
.input_purpose(gtk::InputPurpose::Digits)
|
||||
.build();
|
||||
let ret_err = Label::builder()
|
||||
.css_classes(["error"])
|
||||
.wrap(true)
|
||||
.visible(false)
|
||||
.build();
|
||||
let ret_submit = Button::builder()
|
||||
.label("Return Bike")
|
||||
.css_classes(["destructive-action", "pill"])
|
||||
.build();
|
||||
let ret_spinner = Spinner::new();
|
||||
let electric_msg = Label::builder()
|
||||
.label("This bike has an electric lock.\nJust close the lock to return it.")
|
||||
.wrap(true)
|
||||
.justify(gtk::Justification::Center)
|
||||
.margin_top(24)
|
||||
.build();
|
||||
|
||||
let manual_form = Box::builder()
|
||||
.orientation(Orientation::Vertical)
|
||||
.spacing(12)
|
||||
.build();
|
||||
manual_form.append(&station_entry);
|
||||
manual_form.append(&ret_err);
|
||||
manual_form.append(&ret_submit);
|
||||
manual_form.append(&ret_spinner);
|
||||
|
||||
let ret_inner = Stack::new();
|
||||
ret_inner.add_named(&manual_form, Some("manual"));
|
||||
ret_inner.add_named(&electric_msg, Some("electric"));
|
||||
|
||||
let ret_sheet = Box::builder()
|
||||
.orientation(Orientation::Vertical)
|
||||
.spacing(12)
|
||||
.build();
|
||||
ret_sheet.append(&ret_inner);
|
||||
|
||||
// — Shared sheet stack —
|
||||
let sheet_stack = Stack::new();
|
||||
sheet_stack.add_named(&rent_sheet, Some("rent"));
|
||||
sheet_stack.add_named(&ret_sheet, Some("return"));
|
||||
|
||||
let sheet_box = Box::builder()
|
||||
.orientation(Orientation::Vertical)
|
||||
.margin_top(34)
|
||||
.margin_bottom(18)
|
||||
.margin_start(16)
|
||||
.margin_end(16)
|
||||
.build();
|
||||
sheet_box.append(&sheet_stack);
|
||||
|
||||
let bottom_sheet = BottomSheet::builder()
|
||||
.show_drag_handle(true)
|
||||
.sheet(&sheet_box)
|
||||
.build();
|
||||
|
||||
let main_content = Box::builder().orientation(Orientation::Vertical).build();
|
||||
main_content.append(&scroll);
|
||||
main_content.append(&rent_btn);
|
||||
bottom_sheet.set_content(Some(&main_content));
|
||||
|
||||
let main_body = Box::builder().orientation(Orientation::Vertical).build();
|
||||
main_body.append(&main_hdr);
|
||||
main_body.append(&bottom_sheet);
|
||||
|
||||
let main_page = NavigationPage::builder()
|
||||
.title("NextCompanion")
|
||||
.child(&main_body)
|
||||
.build();
|
||||
|
||||
// ── Navigation view ───────────────────────────────────────────────────────
|
||||
let nav = NavigationView::new();
|
||||
nav.push(&main_page);
|
||||
if loginkey.borrow().is_none() {
|
||||
nav.push(&login_page);
|
||||
}
|
||||
|
||||
// ── Window ────────────────────────────────────────────────────────────────
|
||||
let window = ApplicationWindow::builder()
|
||||
.application(app)
|
||||
.title("NextCompanion")
|
||||
.default_width(390)
|
||||
.default_height(700)
|
||||
.content(&nav)
|
||||
.build();
|
||||
|
||||
// ── Login button ──────────────────────────────────────────────────────────
|
||||
{
|
||||
let phone = phone_entry.clone();
|
||||
let pin = pin_entry.clone();
|
||||
let err = login_err.clone();
|
||||
let spinner = login_spinner.clone();
|
||||
let btn = login_btn.clone();
|
||||
let nav = nav.clone();
|
||||
let loginkey = loginkey.clone();
|
||||
let bikes = bikes.clone();
|
||||
let bikes_list = bikes_list.clone();
|
||||
let list_stack = list_stack.clone();
|
||||
login_btn.connect_clicked(move |_| {
|
||||
let p = phone.text().to_string();
|
||||
let n = pin.text().to_string();
|
||||
if p.is_empty() || n.is_empty() {
|
||||
err.set_label("Phone and PIN are required");
|
||||
err.set_visible(true);
|
||||
return;
|
||||
}
|
||||
err.set_visible(false);
|
||||
spinner.set_spinning(true);
|
||||
btn.set_sensitive(false);
|
||||
|
||||
let spinner = spinner.clone();
|
||||
let btn = btn.clone();
|
||||
let err = err.clone();
|
||||
let nav = nav.clone();
|
||||
let loginkey = loginkey.clone();
|
||||
let bikes = bikes.clone();
|
||||
let bikes_list = bikes_list.clone();
|
||||
let list_stack = list_stack.clone();
|
||||
glib::MainContext::default().spawn_local(async move {
|
||||
let result = match gio::spawn_blocking(move || api_login(&p, &n)).await {
|
||||
Ok(r) => r,
|
||||
Err(_) => Err("Internal error".to_string()),
|
||||
};
|
||||
spinner.set_spinning(false);
|
||||
btn.set_sensitive(true);
|
||||
match result {
|
||||
Ok(key) => {
|
||||
save_loginkey(&key);
|
||||
*loginkey.borrow_mut() = Some(key.clone());
|
||||
nav.pop();
|
||||
load_rentals(key, bikes, bikes_list, list_stack).await;
|
||||
}
|
||||
Err(e) => {
|
||||
err.set_label(&e);
|
||||
err.set_visible(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Logout button ─────────────────────────────────────────────────────────
|
||||
{
|
||||
let nav = nav.clone();
|
||||
let login_page = login_page.clone();
|
||||
let loginkey = loginkey.clone();
|
||||
logout_btn.connect_clicked(move |_| {
|
||||
clear_loginkey();
|
||||
*loginkey.borrow_mut() = None;
|
||||
nav.push(&login_page);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Refresh button ────────────────────────────────────────────────────────
|
||||
{
|
||||
let loginkey = loginkey.clone();
|
||||
let bikes = bikes.clone();
|
||||
let bikes_list = bikes_list.clone();
|
||||
let list_stack = list_stack.clone();
|
||||
refresh_btn.connect_clicked(move |_| {
|
||||
if let Some(key) = loginkey.borrow().clone() {
|
||||
let bikes = bikes.clone();
|
||||
let bikes_list = bikes_list.clone();
|
||||
let list_stack = list_stack.clone();
|
||||
glib::MainContext::default().spawn_local(async move {
|
||||
load_rentals(key, bikes, bikes_list, list_stack).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Open rent sheet ───────────────────────────────────────────────────────
|
||||
{
|
||||
let bottom_sheet = bottom_sheet.clone();
|
||||
let sheet_stack = sheet_stack.clone();
|
||||
let bike_entry = bike_entry.clone();
|
||||
let rent_err = rent_err.clone();
|
||||
rent_btn.connect_clicked(move |_| {
|
||||
bike_entry.set_text("");
|
||||
rent_err.set_visible(false);
|
||||
sheet_stack.set_visible_child_name("rent");
|
||||
bottom_sheet.set_open(true);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Rent submit ───────────────────────────────────────────────────────────
|
||||
{
|
||||
let loginkey = loginkey.clone();
|
||||
let entry = bike_entry.clone();
|
||||
let err = rent_err.clone();
|
||||
let spinner = rent_spinner.clone();
|
||||
let btn = rent_submit.clone();
|
||||
let bottom_sheet = bottom_sheet.clone();
|
||||
let bikes = bikes.clone();
|
||||
let bikes_list = bikes_list.clone();
|
||||
let list_stack = list_stack.clone();
|
||||
rent_submit.connect_clicked(move |_| {
|
||||
let id = entry.text().to_string();
|
||||
if id.is_empty() {
|
||||
err.set_label("Enter a bike number");
|
||||
err.set_visible(true);
|
||||
return;
|
||||
}
|
||||
if let Some(key) = loginkey.borrow().clone() {
|
||||
err.set_visible(false);
|
||||
spinner.set_spinning(true);
|
||||
btn.set_sensitive(false);
|
||||
|
||||
let spinner = spinner.clone();
|
||||
let btn = btn.clone();
|
||||
let err = err.clone();
|
||||
let bottom_sheet = bottom_sheet.clone();
|
||||
let bikes = bikes.clone();
|
||||
let bikes_list = bikes_list.clone();
|
||||
let list_stack = list_stack.clone();
|
||||
let key_reload = key.clone();
|
||||
glib::MainContext::default().spawn_local(async move {
|
||||
let result = match gio::spawn_blocking(move || api_rent(&key, &id)).await {
|
||||
Ok(r) => r,
|
||||
Err(_) => Err("Internal error".to_string()),
|
||||
};
|
||||
spinner.set_spinning(false);
|
||||
btn.set_sensitive(true);
|
||||
if let Err(e) = result {
|
||||
err.set_label(&e);
|
||||
err.set_visible(true);
|
||||
} else {
|
||||
bottom_sheet.set_open(false);
|
||||
load_rentals(key_reload, bikes, bikes_list, list_stack).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Click rental row → open return bottom sheet ───────────────────────────
|
||||
{
|
||||
let bottom_sheet = bottom_sheet.clone();
|
||||
let sheet_stack = sheet_stack.clone();
|
||||
let bikes = bikes.clone();
|
||||
let return_bike = return_bike.clone();
|
||||
let ret_inner = ret_inner.clone();
|
||||
let station_entry = station_entry.clone();
|
||||
let ret_err = ret_err.clone();
|
||||
bikes_list.connect_row_activated(move |_, row| {
|
||||
let idx = row.index() as usize;
|
||||
let bike = bikes.borrow().get(idx).cloned();
|
||||
if let Some(bike) = bike {
|
||||
station_entry.set_text("");
|
||||
ret_err.set_visible(false);
|
||||
ret_inner.set_visible_child_name(if bike.electric_lock { "electric" } else { "manual" });
|
||||
*return_bike.borrow_mut() = Some(bike);
|
||||
sheet_stack.set_visible_child_name("return");
|
||||
bottom_sheet.set_open(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Return submit ─────────────────────────────────────────────────────────
|
||||
{
|
||||
let loginkey = loginkey.clone();
|
||||
let return_bike = return_bike.clone();
|
||||
let entry = station_entry.clone();
|
||||
let err = ret_err.clone();
|
||||
let spinner = ret_spinner.clone();
|
||||
let btn = ret_submit.clone();
|
||||
let bottom_sheet = bottom_sheet.clone();
|
||||
let bikes = bikes.clone();
|
||||
let bikes_list = bikes_list.clone();
|
||||
let list_stack = list_stack.clone();
|
||||
ret_submit.connect_clicked(move |_| {
|
||||
let station = entry.text().to_string();
|
||||
if station.is_empty() {
|
||||
err.set_label("Enter a station number");
|
||||
err.set_visible(true);
|
||||
return;
|
||||
}
|
||||
if let (Some(key), Some(bike)) =
|
||||
(loginkey.borrow().clone(), return_bike.borrow().clone())
|
||||
{
|
||||
err.set_visible(false);
|
||||
spinner.set_spinning(true);
|
||||
btn.set_sensitive(false);
|
||||
|
||||
let spinner = spinner.clone();
|
||||
let btn = btn.clone();
|
||||
let err = err.clone();
|
||||
let bottom_sheet = bottom_sheet.clone();
|
||||
let bikes = bikes.clone();
|
||||
let bikes_list = bikes_list.clone();
|
||||
let list_stack = list_stack.clone();
|
||||
let key_reload = key.clone();
|
||||
glib::MainContext::default().spawn_local(async move {
|
||||
let result = match gio::spawn_blocking(move || {
|
||||
api_return(&key, &bike.id, &station)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(_) => Err("Internal error".to_string()),
|
||||
};
|
||||
spinner.set_spinning(false);
|
||||
btn.set_sensitive(true);
|
||||
if let Err(e) = result {
|
||||
err.set_label(&e);
|
||||
err.set_visible(true);
|
||||
} else {
|
||||
bottom_sheet.set_open(false);
|
||||
load_rentals(key_reload, bikes, bikes_list, list_stack).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Initial rentals load ──────────────────────────────────────────────────
|
||||
if let Some(key) = loginkey.borrow().clone() {
|
||||
let bikes = bikes.clone();
|
||||
let bikes_list = bikes_list.clone();
|
||||
let list_stack = list_stack.clone();
|
||||
glib::MainContext::default().spawn_local(async move {
|
||||
load_rentals(key, bikes, bikes_list, list_stack).await;
|
||||
});
|
||||
}
|
||||
|
||||
window.present();
|
||||
}
|
||||