WIP: Add mobile application #47

Draft
pierre wants to merge 12 commits from mobile-app into main
163 changed files with 6917 additions and 18 deletions
Showing only changes of commit c8fa4552bb - Show all commits

View File

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

View File

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

View File

@ -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()],

View File

@ -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
onPressed: onSettingsPressed, showSettings != false
icon: const Icon(Icons.settings), ? IconButton(
), onPressed: onSettingsPressed,
icon: const Icon(Icons.settings),
)
: Container(),
], ],
), ),
body: SeparatedColumn( body: SeparatedColumn(

View File

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

View File

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

View 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),
],
);
}
}

View File

@ -0,0 +1,4 @@
/// Account API
class AccountAPI {
}

View File

@ -0,0 +1,7 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
/// Client API
@riverpod
class ClientAPI {
}

View 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);
}

View File

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

View File

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

View File

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

View File

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

View File

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