diff --git a/android/app/build.gradle b/android/app/build.gradle index 37ac1c4..089b185 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -41,7 +41,6 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "studio.smartsheep.goatagent" // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. @@ -49,6 +48,10 @@ android { targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName + manifestPlaceholders = [ + applicationName : 'android.app.Application', + appAuthRedirectScheme: 'goatagent' + ] } buildTypes { diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 399f698..4d95910 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -3,5 +3,5 @@ the Flutter tool needs it to communicate with the running application to allow setting breakpoints, to provide hot reload, etc. --> - + diff --git a/android/app/src/main/java/io/flutter/app/FlutterMultiDexApplication.java b/android/app/src/main/java/io/flutter/app/FlutterMultiDexApplication.java new file mode 100644 index 0000000..752fc18 --- /dev/null +++ b/android/app/src/main/java/io/flutter/app/FlutterMultiDexApplication.java @@ -0,0 +1,25 @@ +// Generated file. +// +// If you wish to remove Flutter's multidex support, delete this entire file. +// +// Modifications to this file should be done in a copy under a different name +// as this file may be regenerated. + +package io.flutter.app; + +import android.app.Application; +import android.content.Context; +import androidx.annotation.CallSuper; +import androidx.multidex.MultiDex; + +/** + * Extension of {@link android.app.Application}, adding multidex support. + */ +public class FlutterMultiDexApplication extends Application { + @Override + @CallSuper + protected void attachBaseContext(Context base) { + super.attachBaseContext(base); + MultiDex.install(this); + } +} diff --git a/lib/auth.dart b/lib/auth.dart index 1828bfb..381484a 100644 --- a/lib/auth.dart +++ b/lib/auth.dart @@ -3,12 +3,15 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:goatagent/firebase.dart'; import 'package:goatagent/screens/auth.dart'; import 'package:oauth2/oauth2.dart' as oauth2; class AuthGuard { static final AuthGuard _singleton = AuthGuard._internal(); + final deviceEndpoint = + Uri.parse('https://id.smartsheep.studio/api/notifications/subscribe'); final authorizationEndpoint = Uri.parse('https://id.smartsheep.studio/auth/o/connect'); final tokenEndpoint = @@ -32,12 +35,17 @@ class AuthGuard { Future pickClient() async { if (await storage.containsKey(key: storageKey)) { - var credentials = - oauth2.Credentials.fromJson((await storage.read(key: storageKey))!); - client = oauth2.Client(credentials, - identifier: clientId, secret: clientSecret); - print(await storage.readAll()); - return true; + try { + var credentials = + oauth2.Credentials.fromJson((await storage.read(key: storageKey))!); + client = oauth2.Client(credentials, + identifier: clientId, secret: clientSecret); + await pullProfiles(); + return true; + } catch (e) { + logout(); + return false; + } } else { return false; } @@ -78,18 +86,46 @@ class AuthGuard { } } + Future pullProfiles() async { + if (client != null) { + var userinfo = await client!.get(userinfoEndpoint); + storage.write(key: profileKey, value: utf8.decode(userinfo.bodyBytes)); + } + } + Future login(BuildContext context) async { try { client = await createClient(context); - var userinfo = await client!.read(userinfoEndpoint); - - storage.write(key: profileKey, value: userinfo); storage.write(key: storageKey, value: client!.credentials.toJson()); + + await pullProfiles(); + await subscribeNotify(); } catch (e) { print(e); } } + Future subscribeNotify() async { + if (client == null) { + return; + } + + var token = await initializeFirebaseMessaging(); + if (token == null) { + print("failed to initialize firebase messaging..."); + return; + } + + var response = await client!.post( + deviceEndpoint, + headers: {"Content-Type": "application/json"}, + body: jsonEncode({"device_id": token, "provider": "firebase"}), + ); + if (response.statusCode != 200) { + print(response.body); + } + } + void logout() { try { storage.delete(key: profileKey); diff --git a/lib/firebase.dart b/lib/firebase.dart index 7fb29be..86c51de 100644 --- a/lib/firebase.dart +++ b/lib/firebase.dart @@ -11,6 +11,16 @@ void initializeFirebase() async { options: DefaultFirebaseOptions.currentPlatform, ); + await FirebaseMessaging.instance.requestPermission( + alert: true, + announcement: false, + badge: true, + carPlay: false, + criticalAlert: false, + provisional: false, + sound: true, + ); + FlutterError.onError = (errorDetails) { FirebaseCrashlytics.instance.recordFlutterFatalError(errorDetails); }; diff --git a/lib/layouts/navigation.dart b/lib/layouts/navigation.dart index 63bc216..967b770 100644 --- a/lib/layouts/navigation.dart +++ b/lib/layouts/navigation.dart @@ -1,3 +1,4 @@ +import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -11,13 +12,17 @@ class AgentNavigation extends StatefulWidget { icon: Icon(Icons.home), label: 'Dashboard', ), + NavigationDestination( + icon: Icon(Icons.notifications), + label: 'Notifications', + ), NavigationDestination( icon: Icon(Icons.account_circle), label: 'Account', ) ]; - static const destinations = ["/", "/account"]; + static const destinations = ["/", "/notifications", "/account"]; @override State createState() => _AgentNavigationState(); @@ -26,6 +31,32 @@ class AgentNavigation extends StatefulWidget { class _AgentNavigationState extends State { int _selected = 0; + Future initMessage(BuildContext context) async { + void navigate() { + widget.router.push("/notifications"); + setState(() { + _selected = 1; + }); + } + + RemoteMessage? initialMessage = + await FirebaseMessaging.instance.getInitialMessage(); + + if (initialMessage != null) { + navigate(); + } + + FirebaseMessaging.onMessageOpenedApp.listen((event) { + navigate(); + }); + } + + @override + void initState() { + super.initState(); + initMessage(context); + } + @override Widget build(BuildContext context) { return NavigationBar( diff --git a/lib/main.dart b/lib/main.dart index 30bb1f0..ecb1e94 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,10 +1,11 @@ +import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:goatagent/auth.dart'; -import 'package:goatagent/screens/auth.dart'; import 'package:goatagent/firebase.dart'; import 'package:goatagent/screens/account.dart'; import 'package:goatagent/screens/dashboard.dart'; +import 'package:goatagent/screens/notifications.dart'; import 'layouts/navigation.dart'; @@ -21,9 +22,18 @@ class GoatAgent extends StatelessWidget { final _router = GoRouter( navigatorKey: GlobalKey(), routes: [ - GoRoute(path: '/', builder: (context, state) => const DashboardScreen()), GoRoute( - path: '/account', builder: (context, state) => const AccountScreen()) + path: '/', + builder: (context, state) => const DashboardScreen(), + ), + GoRoute( + path: '/notifications', + builder: (context, state) => const NotificationScreen(), + ), + GoRoute( + path: '/account', + builder: (context, state) => const AccountScreen(), + ), ], ); @@ -41,11 +51,12 @@ class GoatAgent extends StatelessWidget { builder: (BuildContext context, Widget? child) { return Overlay(initialEntries: [ OverlayEntry( - builder: (context) => Scaffold( - body: child, - // bottomNavigationBar: const AgentBottomNavigation() - bottomNavigationBar: AgentNavigation(router: _router), - )) + builder: (context) => Scaffold( + body: child, + // bottomNavigationBar: const AgentBottomNavigation() + bottomNavigationBar: AgentNavigation(router: _router), + ), + ) ]); }, ); diff --git a/lib/screens/account.dart b/lib/screens/account.dart index 2d60910..079b701 100644 --- a/lib/screens/account.dart +++ b/lib/screens/account.dart @@ -30,14 +30,17 @@ class _AccountScreenState extends State { padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 20), child: Column( children: [ - NameCard( - onLogin: () async { - await AuthGuard().login(context); - var authorized = await AuthGuard().isAuthorized(); - setState(() { - isAuthorized = authorized; - }); - }, + Padding( + padding: const EdgeInsets.only(top: 20), + child: NameCard( + onLogin: () async { + await AuthGuard().login(context); + var authorized = await AuthGuard().isAuthorized(); + setState(() { + isAuthorized = authorized; + }); + }, + ), ), FutureBuilder( future: AuthGuard().isAuthorized(), diff --git a/lib/screens/notifications.dart b/lib/screens/notifications.dart new file mode 100644 index 0000000..b6c63fe --- /dev/null +++ b/lib/screens/notifications.dart @@ -0,0 +1,96 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:goatagent/auth.dart'; + +class NotificationScreen extends StatefulWidget { + const NotificationScreen({super.key}); + + @override + State createState() => _NotificationScreenState(); +} + +class _NotificationScreenState extends State { + final notificationEndpoint = Uri.parse( + 'https://id.smartsheep.studio/api/notifications?skip=0&take=20'); + + List notifications = List.empty(); + + @override + void initState() { + super.initState(); + _pullNotifications(); + } + + Future _pullNotifications() async { + if (await AuthGuard().isAuthorized()) { + await AuthGuard().pullProfiles(); + var profiles = await AuthGuard().readProfiles(); + setState(() { + notifications = profiles['notifications']; + }); + } + } + + Future _markAsRead(element) async { + if (AuthGuard().client != null) { + var id = element['id']; + var uri = + Uri.parse('https://id.smartsheep.studio/api/notifications/$id/read'); + await AuthGuard().client!.put(uri); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.only(left: 20, right: 20, top: 30), + child: RefreshIndicator( + onRefresh: _pullNotifications, + child: CustomScrollView( + slivers: [ + notifications.isEmpty + ? const SliverToBoxAdapter( + child: Card( + child: Padding( + padding: EdgeInsets.all(10), + child: ListTile( + leading: Icon(Icons.check), + title: Text('You\'re done!'), + subtitle: Text( + 'There are no notifications unread for you.'), + ), + ), + ), + ) + : SliverList.builder( + itemCount: notifications.length, + itemBuilder: (BuildContext context, int index) { + var element = notifications[index]; + return Dismissible( + key: Key('notification-$index'), + onDismissed: (direction) { + var subject = element["subject"]; + _markAsRead(element).then((value) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: + Text('「$subject」 mark as read'))); + }); + }, + child: ListTile( + title: Text(element["subject"]), + subtitle: Text(element["content"]), + )); + }, + ), + ], + ), + ), + ), + ), + ); + } +}