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

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