Compare commits

...

10 commits

Author SHA1 Message Date
9961a31936 ui fix 2026-03-10 23:58:45 +01:00
5e4560e89e add bottomsheet navigation 2026-03-10 23:31:50 +01:00
3490cd6ed9 init 2026-03-10 23:25:26 +01:00
lgehr
f810a0f645 Recommiting strings.xml files without old apikey 2022-03-20 23:33:18 +01:00
h0chi
5a277dd869 Merge pull request #31 from loewenzahm/master
[Bugfix] Fix logout bug
2022-02-07 22:33:58 +01:00
loewenzahm
0df2b02ad5 [Bugfix] Fix logout bug
On a logout the login-key has not been removed from the shared preferences due to a typo ("loginkey" instead of "loginKey"). Now that the typo is fixed the login key is no longer kept stored after logouts which resolves the bug mentioned in issue #16
2022-02-07 22:24:29 +01:00
lgehr
4ed7f5d54a Bumps to version 0.1.7.2 2021-10-01 23:36:48 +02:00
2xlink
66586f83b9 Removes comments 2021-10-01 21:44:42 +02:00
2xlink
5ab4301aca Adds app name to open with dialog 2021-10-01 21:44:42 +02:00
2xlink
56a420bb7a Adds intent listener for nxtb.it links 2021-10-01 21:44:42 +02:00
59 changed files with 4385 additions and 1341 deletions

5
.gitignore vendored
View file

@ -6,3 +6,8 @@
/captures /captures
.externalNativeBuild .externalNativeBuild
.idea/* .idea/*
# Added by cargo
/target

2068
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

11
Cargo.toml Normal file
View 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
View file

@ -1 +0,0 @@
/build

View file

@ -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'
}

View file

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

View file

@ -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());
}
}

View file

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

View file

@ -1,5 +0,0 @@
package com.example.hochi.nextcompanion;
interface AsyncTaskCallbacks<T> {
void onTaskComplete(T response);
}

View file

@ -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();
}
}
}

View file

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

View file

@ -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();
}
}

View file

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

View file

@ -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();
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

View file

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

View file

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

View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#000000</color>
</resources>

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,5 @@
[source.crates-io]
replace-with = "vendored-sources"
[source.vendored-sources]
directory = "/run/build/next-companion/cargo-vendor"

View 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()

View file

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

View file

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before After
Before After

62
flake.lock generated Normal file
View 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
View 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";
};
};
}

View file

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

Binary file not shown.

View file

@ -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
View file

@ -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
View file

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

View 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
View file

@ -0,0 +1 @@
/nix/store/gan72x7dji7c6lwngwfx88my14azg9wn-next-companion-0.1.0

View file

@ -1 +0,0 @@
include ':app'

608
src/main.rs Normal file
View 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();
}