WIP: Add mobile application #47
@ -3,5 +3,8 @@
|
|||||||
the Flutter tool needs it to communicate with the running application
|
the Flutter tool needs it to communicate with the running application
|
||||||
to allow setting breakpoints, to provide hot reload, etc.
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
-->
|
-->
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<!-- In debug mode, unsecure traffic is permitted -->
|
||||||
|
<application android:usesCleartextTraffic="true" />
|
||||||
</manifest>
|
</manifest>
|
||||||
|
@ -42,4 +42,7 @@
|
|||||||
<data android:mimeType="text/plain"/>
|
<data android:mimeType="text/plain"/>
|
||||||
</intent>
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
|
|
||||||
|
<!-- Communication with backend -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
import 'package:moneymgr_mobile/providers/settings.dart';
|
import 'package:moneymgr_mobile/providers/settings.dart';
|
||||||
import 'package:moneymgr_mobile/services/router/router.dart';
|
import 'package:moneymgr_mobile/services/router/router.dart';
|
||||||
import 'package:moneymgr_mobile/services/storage/prefs.dart';
|
import 'package:moneymgr_mobile/services/storage/prefs.dart';
|
||||||
@ -18,6 +19,14 @@ Future<void> main() async {
|
|||||||
// app is inserted to the widget tree.
|
// app is inserted to the widget tree.
|
||||||
FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
|
FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
|
||||||
|
|
||||||
|
// Configure logger
|
||||||
|
Logger.root.level = Level.ALL; // defaults to Level.INFO
|
||||||
|
Logger.root.onRecord.listen(
|
||||||
|
(record) =>
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('${record.level.name}: ${record.time}: ${record.message}'),
|
||||||
|
);
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
observers: [AppProviderObserver()],
|
observers: [AppProviderObserver()],
|
||||||
|
@ -7,8 +7,15 @@ import '../../services/router/routes_list.dart';
|
|||||||
|
|
||||||
class BaseAuthPage extends StatelessWidget {
|
class BaseAuthPage extends StatelessWidget {
|
||||||
final List<Widget> children;
|
final List<Widget> children;
|
||||||
|
final String? title;
|
||||||
|
final bool? showSettings;
|
||||||
|
|
||||||
const BaseAuthPage({super.key, required this.children});
|
const BaseAuthPage({
|
||||||
|
super.key,
|
||||||
|
required this.children,
|
||||||
|
this.title,
|
||||||
|
this.showSettings,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -16,12 +23,15 @@ class BaseAuthPage extends StatelessWidget {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('MoneyMgr'),
|
title: Text(title ?? 'MoneyMgr'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
// Settings button
|
||||||
|
showSettings != false
|
||||||
|
? IconButton(
|
||||||
onPressed: onSettingsPressed,
|
onPressed: onSettingsPressed,
|
||||||
icon: const Icon(Icons.settings),
|
icon: const Icon(Icons.settings),
|
||||||
),
|
)
|
||||||
|
: Container(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: SeparatedColumn(
|
body: SeparatedColumn(
|
||||||
|
@ -1,16 +0,0 @@
|
|||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
|
||||||
|
|
||||||
part 'login_model.freezed.dart';
|
|
||||||
part 'login_model.g.dart';
|
|
||||||
|
|
||||||
@freezed
|
|
||||||
abstract class LoginModel with _$LoginModel {
|
|
||||||
const factory LoginModel({
|
|
||||||
// TODO : replace
|
|
||||||
required String username,
|
|
||||||
required String password,
|
|
||||||
}) = _LoginModel;
|
|
||||||
|
|
||||||
factory LoginModel.fromJson(Map<String, dynamic> json) =>
|
|
||||||
_$LoginModelFromJson(json);
|
|
||||||
}
|
|
@ -36,7 +36,6 @@ class LoginScreen extends HookConsumerWidget {
|
|||||||
|
|
||||||
class _LoginChoice extends StatelessWidget {
|
class _LoginChoice extends StatelessWidget {
|
||||||
const _LoginChoice({
|
const _LoginChoice({
|
||||||
super.key,
|
|
||||||
required this.route,
|
required this.route,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
|
85
moneymgr_mobile/lib/routes/login/manual_auth_screen.dart
Normal file
85
moneymgr_mobile/lib/routes/login/manual_auth_screen.dart
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_gutter/flutter_gutter.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:moneymgr_mobile/routes/login/base_auth_page.dart';
|
||||||
|
import 'package:moneymgr_mobile/services/api/api_token.dart';
|
||||||
|
import 'package:moneymgr_mobile/services/auth_state.dart';
|
||||||
|
import 'package:moneymgr_mobile/utils/extensions.dart';
|
||||||
|
import 'package:moneymgr_mobile/widgets/app_button.dart';
|
||||||
|
|
||||||
|
class ManualAuthScreen extends HookConsumerWidget {
|
||||||
|
const ManualAuthScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
var apiUrlController = useTextEditingController();
|
||||||
|
var tokenIdController = useTextEditingController();
|
||||||
|
var tokenValueController = useTextEditingController();
|
||||||
|
|
||||||
|
Future<void> onSubmit() async {
|
||||||
|
try {
|
||||||
|
await ref
|
||||||
|
.read(currentAuthStateProvider.notifier)
|
||||||
|
.setAuthToken(
|
||||||
|
ApiToken(
|
||||||
|
apiUrl: apiUrlController.text,
|
||||||
|
tokenId: int.tryParse(tokenIdController.text) ?? 1,
|
||||||
|
tokenValue: tokenValueController.text,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e, s) {
|
||||||
|
Logger.root.severe("Failed to authenticate user! $e $s");
|
||||||
|
if (context.mounted) {
|
||||||
|
context.showTextSnackBar("Failed to authenticate user! $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return BaseAuthPage(
|
||||||
|
title: "Manual authentication",
|
||||||
|
showSettings: false,
|
||||||
|
children: [
|
||||||
|
Gutter(scaleFactor: 3),
|
||||||
|
Text(
|
||||||
|
"On this screen you can manually enter authentication information.",
|
||||||
|
),
|
||||||
|
Gutter(),
|
||||||
|
TextField(
|
||||||
|
controller: apiUrlController,
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'API URL',
|
||||||
|
helperText:
|
||||||
|
"This URL has usually this format: http://moneymgr.corp.com/api",
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
Gutter(),
|
||||||
|
TextField(
|
||||||
|
controller: tokenIdController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Token ID',
|
||||||
|
helperText: "The ID of the token",
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
Gutter(),
|
||||||
|
TextField(
|
||||||
|
controller: tokenIdController,
|
||||||
|
keyboardType: TextInputType.text,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Token value',
|
||||||
|
helperText: "The value of the token itself",
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
),
|
||||||
|
Gutter(),
|
||||||
|
AppButton(onPressed: onSubmit, label: "Submit"),
|
||||||
|
Gutter(scaleFactor: 3),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
4
moneymgr_mobile/lib/services/api/account_api.dart
Normal file
4
moneymgr_mobile/lib/services/api/account_api.dart
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
/// Account API
|
||||||
|
class AccountAPI {
|
||||||
|
|
||||||
|
}
|
7
moneymgr_mobile/lib/services/api/api_client.dart
Normal file
7
moneymgr_mobile/lib/services/api/api_client.dart
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
/// Client API
|
||||||
|
@riverpod
|
||||||
|
class ClientAPI {
|
||||||
|
|
||||||
|
}
|
16
moneymgr_mobile/lib/services/api/api_token.dart
Normal file
16
moneymgr_mobile/lib/services/api/api_token.dart
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'api_token.freezed.dart';
|
||||||
|
part 'api_token.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class ApiToken with _$ApiToken {
|
||||||
|
const factory ApiToken({
|
||||||
|
required String apiUrl,
|
||||||
|
required int tokenId,
|
||||||
|
required String tokenValue
|
||||||
|
}) = _ApiToken;
|
||||||
|
|
||||||
|
factory ApiToken.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ApiTokenFromJson(json);
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import 'package:moneymgr_mobile/routes/login/login_model.dart';
|
import 'package:moneymgr_mobile/services/api/api_token.dart';
|
||||||
import 'package:moneymgr_mobile/services/router/routes_list.dart';
|
import 'package:moneymgr_mobile/services/router/routes_list.dart';
|
||||||
import 'package:moneymgr_mobile/services/storage/secure_storage.dart';
|
import 'package:moneymgr_mobile/services/storage/secure_storage.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
@ -8,7 +8,7 @@ part 'auth_state.g.dart';
|
|||||||
/// The current authentication state of the app.
|
/// The current authentication state of the app.
|
||||||
///
|
///
|
||||||
/// This notifier is responsible for saving/removing the token and profile info
|
/// This notifier is responsible for saving/removing the token and profile info
|
||||||
/// to the storage through the [login] and [logout] methods.
|
/// to the storage through the [setAuthToken] and [logout] methods.
|
||||||
@riverpod
|
@riverpod
|
||||||
class CurrentAuthState extends _$CurrentAuthState {
|
class CurrentAuthState extends _$CurrentAuthState {
|
||||||
@override
|
@override
|
||||||
@ -18,9 +18,9 @@ class CurrentAuthState extends _$CurrentAuthState {
|
|||||||
return token != null ? AuthState.authenticated : AuthState.unauthenticated;
|
return token != null ? AuthState.authenticated : AuthState.unauthenticated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attempts to log in with [data] and saves the token and profile info to storage.
|
/// Attempts to authenticate with [data] and saves the token and profile info to storage.
|
||||||
/// Will invalidate the state if success.
|
/// Will invalidate the state if success.
|
||||||
Future<void> login(LoginModel data) async {
|
Future<void> setAuthToken(ApiToken data) async {
|
||||||
// TODO : perform login
|
// TODO : perform login
|
||||||
/*final secureStorage = ref.read(secureStorageProvider).requireValue;
|
/*final secureStorage = ref.read(secureStorageProvider).requireValue;
|
||||||
final token = await ref.read(apiServiceProvider).login(data);
|
final token = await ref.read(apiServiceProvider).login(data);
|
||||||
@ -53,13 +53,12 @@ class CurrentAuthState extends _$CurrentAuthState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// The possible authentication states of the app.
|
/// The possible authentication states of the app.
|
||||||
enum AuthState {
|
enum AuthState {
|
||||||
unknown(redirectPath: homePage, allowedPaths: [homePage]),
|
unknown(redirectPath: homePage, allowedPaths: [homePage]),
|
||||||
unauthenticated(
|
unauthenticated(
|
||||||
redirectPath: authPage,
|
redirectPath: authPage,
|
||||||
allowedPaths: [authPage, settingsPage],
|
allowedPaths: [authPage, manualAuthPage, settingsPage],
|
||||||
),
|
),
|
||||||
authenticated(redirectPath: homePage, allowedPaths: null);
|
authenticated(redirectPath: homePage, allowedPaths: null);
|
||||||
|
|
||||||
|
@ -2,13 +2,12 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:moneymgr_mobile/routes/login/login_screen.dart';
|
import 'package:moneymgr_mobile/routes/login/login_screen.dart';
|
||||||
|
import 'package:moneymgr_mobile/routes/login/manual_auth_screen.dart';
|
||||||
import 'package:moneymgr_mobile/routes/settings/settings_screen.dart';
|
import 'package:moneymgr_mobile/routes/settings/settings_screen.dart';
|
||||||
import 'package:moneymgr_mobile/services/auth_state.dart';
|
import 'package:moneymgr_mobile/services/auth_state.dart';
|
||||||
import 'package:moneymgr_mobile/services/router/routes_list.dart';
|
import 'package:moneymgr_mobile/services/router/routes_list.dart';
|
||||||
import 'package:moneymgr_mobile/widgets/scaffold_with_navigation.dart';
|
import 'package:moneymgr_mobile/widgets/scaffold_with_navigation.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
|
||||||
part 'router.g.dart';
|
part 'router.g.dart';
|
||||||
|
|
||||||
/// The router config for the app.
|
/// The router config for the app.
|
||||||
@ -62,9 +61,10 @@ GoRouter router(Ref ref) {
|
|||||||
GoRoute(path: homePage, builder: (_, __) => const Scaffold()),
|
GoRoute(path: homePage, builder: (_, __) => const Scaffold()),
|
||||||
GoRoute(path: authPage, builder: (_, __) => const LoginScreen()),
|
GoRoute(path: authPage, builder: (_, __) => const LoginScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: settingsPage,
|
path: manualAuthPage,
|
||||||
builder: (_, __) => const SettingsScreen(),
|
builder: (_, __) => const ManualAuthScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(path: settingsPage, builder: (_, __) => const SettingsScreen()),
|
||||||
|
|
||||||
// Configuration for the bottom navigation bar routes. The routes themselves
|
// Configuration for the bottom navigation bar routes. The routes themselves
|
||||||
// should be defined in [navigationItems]. Modification to this [ShellRoute]
|
// should be defined in [navigationItems]. Modification to this [ShellRoute]
|
||||||
|
@ -6,25 +6,23 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||||||
import 'package:moneymgr_mobile/utils/extensions.dart';
|
import 'package:moneymgr_mobile/utils/extensions.dart';
|
||||||
import 'package:moneymgr_mobile/utils/hooks.dart';
|
import 'package:moneymgr_mobile/utils/hooks.dart';
|
||||||
|
|
||||||
|
|
||||||
/// A button that shows a circular progress indicator when the [onPressed] callback
|
/// A button that shows a circular progress indicator when the [onPressed] callback
|
||||||
/// is pending.
|
/// is pending.
|
||||||
class AppButton extends HookWidget {
|
class AppButton extends HookWidget {
|
||||||
const AppButton({
|
const AppButton({super.key, required this.onPressed, required this.label});
|
||||||
super.key,
|
|
||||||
required this.onPressed,
|
|
||||||
required this.label,
|
|
||||||
});
|
|
||||||
|
|
||||||
final AsyncCallback? onPressed;
|
final AsyncCallback? onPressed;
|
||||||
final String label;
|
final String label;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final (:pending, :snapshot, hasError: _) = useAsyncTask();
|
final (:pending, :snapshot, :hasError) = useAsyncTask();
|
||||||
|
|
||||||
return FilledButton(
|
return FilledButton(
|
||||||
onPressed: onPressed == null ? null : () => pending.value = onPressed!(),
|
onPressed: onPressed == null ? null : () => pending.value = onPressed!(),
|
||||||
|
style: ButtonStyle(
|
||||||
|
backgroundColor: hasError ? WidgetStatePropertyAll(Colors.red) : null,
|
||||||
|
),
|
||||||
child: SeparatedRow(
|
child: SeparatedRow(
|
||||||
separatorBuilder: () => const GutterSmall(),
|
separatorBuilder: () => const GutterSmall(),
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
@ -537,7 +537,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "5.1.1"
|
version: "5.1.1"
|
||||||
logging:
|
logging:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: logging
|
name: logging
|
||||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||||
|
@ -61,8 +61,12 @@ dependencies:
|
|||||||
# Help in models building
|
# Help in models building
|
||||||
freezed_annotation: ^3.0.0
|
freezed_annotation: ^3.0.0
|
||||||
|
|
||||||
|
# For JSON serialization
|
||||||
json_annotation: ^4.9.0
|
json_annotation: ^4.9.0
|
||||||
|
|
||||||
|
# Logger
|
||||||
|
logging: ^1.3.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
Reference in New Issue
Block a user