Skip to content

Commit

Permalink
Mastodon sync feature is implemented (#23)
Browse files Browse the repository at this point in the history
* Update flutter.versionCode to 4 and flutter.versionName to 1.0.1

* update dio client with timeouts

* update: add flutter_web_auth dependency

* update: rename NoteSyncSettings to TelegramSyncSettings

* add: implement Mastodon user account API and services

* add mastodon config done

* remove refreshToken from MastodonUserAccount

* Add Mastodon sync type feature and improve settings UI
  • Loading branch information
shukebeta authored Oct 28, 2024
1 parent bc5d086 commit 6955d2a
Show file tree
Hide file tree
Showing 22 changed files with 713 additions and 21 deletions.
4 changes: 2 additions & 2 deletions .env.production
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ UPLOADER_BASE_URL=https://happynotes-img-uploader.shukebeta.com
DEBUGGING=0
#keep this line, it will be replace the commit hash laterly
VERSION=VERSION_PLACEHOLDER
FLUTTER_VERSION_CODE=3
FLUTTER_VERSION_NAME=1.0.0
FLUTTER_VERSION_CODE=4
FLUTTER_VERSION_Name=1.0.1
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- **Quick Actions**: Capture notes swiftly on Android and iOS.
- **Public & Private Notes**: Share with others or keep as a personal journal.
- **Telegram Sync**: Sync notes to a Telegram channel/group with flexible rules.
- **Mastodon Sync**: Sync all notes or public notes or notes with mastodon tag to your Mastodon account.
- **Open Source**: Contribute to the features you love.

## Tips for Using Happy Notes
Expand Down
7 changes: 6 additions & 1 deletion bin/build-bundle
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,13 @@ sed -i "s/^flutter.versionName=.*/flutter.versionName=$new_version_name/" "$prop
sed -i "s/^FLUTTER_VERSION_CODE=.*/FLUTTER_VERSION_CODE=$new_version_code/" .env.production
sed -i "s/^FLUTTER_VERSION_NAME=.*/FLUTTER_VERSION_Name=$new_version_name/" .env.production

# Set the version number in pubspec.yaml
sed -i "s/^version: .*$/version: $new_version_name/" pubspec.yaml

# Add all changes to Git
git add pubspec.yaml .env.production

# Commit the changes
git add .env.production
git commit -m "Update flutter.versionCode to $new_version_code and flutter.versionName to $new_version_name"

cp .env.production .env
Expand Down
29 changes: 29 additions & 0 deletions lib/apis/mastodon_application_api.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import 'package:dio/dio.dart';
import '../app_config.dart';
import '../dio_client.dart';
import '../entities/mastodon_application.dart';

class MastodonApplicationApi {
static final Dio _dio = DioClient.getInstance();

Future<Response> createApplication(String instanceUrl) async {
final options = Options(
headers: {'AllowAnonymous': true},
);
// register an application on the instance
return await _dio.post('$instanceUrl/api/v1/apps', data: {
'client_name': 'Happy Notes',
'redirect_uris': AppConfig.mastodonRedirectUri(instanceUrl),
'scopes': 'read write follow',
'website': 'https://happynotes.shukebeta.com'
});
}

Future<Response> get(String instanceUrl) async {
return await _dio.get('/mastodonApplication/get', queryParameters: {'instanceUrl': instanceUrl});
}

Future<Response> save(MastodonApplication app) async {
return await _dio.post('/mastodonApplication/save', data: app.toJson());
}
}
54 changes: 54 additions & 0 deletions lib/apis/mastodon_user_account_api.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import 'package:dio/dio.dart';
import 'package:happy_notes/entities/mastodon_user_account.dart';
import '../dio_client.dart';

class MastodonUserAccountApi {
static final Dio _dio = DioClient.getInstance();

Future<Response> setState(String state) async {
final options = Options(
headers: {'X-State': state},
);
// we don't have a separate api file for mastodon auth
return await _dio.post('/mastodonAuth/setState', options: options);
}

Future<Response> getAll() async {
return await _dio.get('/mastodonUserAccount/getAll');
}

Future<Response> add(MastodonUserAccount account) async {
var data = _getPostData(account);
return await _dio.post('/mastodonUserAccount/add', data: data);
}

Future<Response> nextSyncType(MastodonUserAccount account) async {
var data = _getPostData(account);
return await _dio.post('/mastodonUserAccount/nextSyncType', data: data);
}

Future<Response> activate(MastodonUserAccount account) async {
var data = _getPostData(account);
return await _dio.post('/mastodonUserAccount/activate', data: data);
}

Map<String, Object?> _getPostData(MastodonUserAccount account) {
return {
'userId': account.userId,
'instanceUrl': account.instanceUrl,
'scope': account.scope,
'accessToken': account.accessToken,
'tokenType': account.tokenType,
};
}

Future<Response> disable(MastodonUserAccount account) async {
var data = _getPostData(account);
return await _dio.post('/mastodonUserAccount/disable', data: data);
}

Future<Response> delete(MastodonUserAccount account) async {
var data = _getPostData(account);
return await _dio.delete('/mastodonUserAccount/delete', data: data);
}
}
4 changes: 4 additions & 0 deletions lib/app_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,8 @@ class AppConfig {
throw ArgumentError('No such property: $name');
}
}

static String mastodonRedirectUri(String instanceUrl) {
return '$apiBaseUrl/mastodonAuth/callback?instanceUrl=${Uri.encodeFull(instanceUrl)}';
}
}
17 changes: 15 additions & 2 deletions lib/dependency_injection.dart
Original file line number Diff line number Diff line change
@@ -1,21 +1,28 @@
import 'package:happy_notes/apis/account_api.dart';
import 'package:happy_notes/apis/file_uploader_api.dart';
import 'package:happy_notes/apis/user_settings_api.dart';
import 'package:happy_notes/entities/mastodon_user_account.dart';
import 'package:happy_notes/screens/discovery/discovery_controller.dart';
import 'package:happy_notes/screens/home_page/home_page_controller.dart';
import 'package:happy_notes/screens/new_note/new_note_controller.dart';
import 'package:happy_notes/screens/settings/note_sync_settings_controller.dart';
import 'package:happy_notes/screens/settings/mastodon_sync_settings_controller.dart';
import 'package:happy_notes/screens/settings/telegram_sync_settings_controller.dart';
import 'package:happy_notes/screens/settings/settings_controller.dart';
import 'package:happy_notes/screens/tag_notes/tag_notes_controller.dart';
import 'package:happy_notes/services/account_service.dart';
import 'package:happy_notes/services/image_service.dart';
import 'package:happy_notes/services/mastodon_application_service.dart';
import 'package:happy_notes/services/mastodon_service.dart';
import 'package:happy_notes/services/mastodon_user_account_service.dart';
import 'package:happy_notes/services/note_tag_service.dart';
import 'package:happy_notes/services/notes_services.dart';
import 'package:get_it/get_it.dart';
import 'package:happy_notes/services/telegram_settings_service.dart';
import 'package:happy_notes/services/user_settings_service.dart';
import 'package:happy_notes/utils/token_utils.dart';

import 'apis/mastodon_application_api.dart';
import 'apis/mastodon_user_account_api.dart';
import 'apis/note_tag_api.dart';
import 'apis/telegram_settings_api.dart';

Expand All @@ -34,6 +41,8 @@ void _registerApis() {
locator.registerLazySingleton(() => AccountApi());
locator.registerLazySingleton(() => UserSettingsApi());
locator.registerLazySingleton(() => TelegramSettingsApi());
locator.registerLazySingleton(() => MastodonApplicationApi());
locator.registerLazySingleton(() => MastodonUserAccountApi());
}

void _registerServices() {
Expand All @@ -47,14 +56,18 @@ void _registerServices() {
));
locator.registerLazySingleton(() => UserSettingsService(userSettingsApi: locator()));
locator.registerLazySingleton(() => TelegramSettingsService(telegramSettingsApi: locator()));
locator.registerLazySingleton(() => MastodonApplicationService(mastodonApplicationApi: locator()));
locator.registerLazySingleton(() => MastodonUserAccountService(mastodonUserAccountApi: locator()));
locator.registerLazySingleton(() => MastodonService(mastodonApplicationService: locator(), mastodonUserAccountService: locator()));
}

void _registerControllers() {
locator.registerFactory(() => SettingsController(
accountService: locator(),
userSettingsService: locator(),
));
locator.registerLazySingleton(() => NoteSyncSettingsController(telegramSettingService: locator()));
locator.registerLazySingleton(() => TelegramSyncSettingsController(telegramSettingService: locator()));
locator.registerLazySingleton(() => MastodonSyncSettingsController(mastodonUserAccountService: locator()));
locator.registerFactory(() => NewNoteController(notesService: locator()));
locator.registerFactory(() => HomePageController(
notesService: locator(),
Expand Down
5 changes: 4 additions & 1 deletion lib/dio_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ class DioClient {
if (_dio == null) {
_dio = Dio(); // Create Dio instance if not already created
_dio!.options.baseUrl = AppConfig.apiBaseUrl;
_dio!.options.connectTimeout = const Duration(seconds: 10);
_dio!.options.receiveTimeout = const Duration(seconds: 15);
_dio!.options.sendTimeout = const Duration(seconds: 10);

_dio!.interceptors.add(LogInterceptor(requestBody: true, responseBody: true)); // Add logging interceptor
_dio!.interceptors.add(AuthInterceptor());
_dio!.interceptors.add(InterceptorsWrapper(
Expand All @@ -33,4 +37,3 @@ class DioClient {
return _dio!;
}
}

63 changes: 63 additions & 0 deletions lib/entities/mastodon_application.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
class MastodonApplication {
int? id;
final String instanceUrl;
final int applicationId;
final String clientId;
final String clientSecret;
final String redirectUri;
final String scopes;
final String name;
final String website;
int? createAt;
int? updateAt;
int? maxTootChars = 500;

MastodonApplication({
this.id,
required this.instanceUrl,
required this.applicationId,
required this.clientId,
required this.clientSecret,
required this.redirectUri,
required this.scopes,
required this.name,
required this.website,
this.createAt,
this.updateAt,
this.maxTootChars,
});

factory MastodonApplication.fromJson(Map<String, dynamic> json) {
return MastodonApplication(
id: json['id'],
instanceUrl: json['instanceUrl'],
applicationId: json['applicationId'],
clientId: json['clientId'],
clientSecret: json['clientSecret'],
maxTootChars: json['maxTootChars'],
redirectUri: json['redirectUri'],
scopes: json['scopes'],
name: json['name'],
website: json['website'],
createAt: json['createAt'],
updateAt: json['updateAt'],
);
}

Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['instanceUrl'] = instanceUrl;
data['applicationId'] = applicationId;
data['clientId'] = clientId;
data['clientSecret'] = clientSecret;
data['maxTootChars'] = maxTootChars;
data['redirectUri'] = redirectUri;
data['scopes'] = scopes;
data['name'] = name;
data['website'] = website;
data['createAt'] = createAt;
data['updateAt'] = updateAt;
return data;
}
}
63 changes: 63 additions & 0 deletions lib/entities/mastodon_user_account.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
class MastodonUserAccount {
int? id;
int? userId;
final int? status;
final int syncType;
final String? instanceUrl;
final String? scope;

String accessToken;
String tokenType;
String? statusText = '';

String? get syncTypeText {
switch (syncType) {
case 1:
return 'All';
case 2:
return 'Public note only';
case 3:
return 'Note with tag Mastodon only';
default:
return 'Unknown';
}
}

bool get isActive {
return statusText == 'Normal';
}

bool get isDisabled {
return statusText == 'Disabled' || (statusText ?? '').contains('Inactive');
}

bool get isTested {
return statusText == 'Created' || statusText == 'Normal' || statusText == 'Disabled';
}

MastodonUserAccount({
this.id,
this.userId,
required this.instanceUrl,
required this.scope,
required this.accessToken,
required this.tokenType,
required this.syncType,
this.status,
this.statusText,
});

factory MastodonUserAccount.fromJson(Map<String, dynamic> json) {
return MastodonUserAccount(
id: json['id'],
userId: json['userId'],
instanceUrl: json['instanceUrl'],
scope: json['scope'],
accessToken: json['accessToken'],
tokenType: json['tokenType'],
status: json['status'],
syncType: json['syncType'],
statusText: json['statusText'],
);
}
}
69 changes: 69 additions & 0 deletions lib/screens/settings/add_mastodon_user_account.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import 'package:happy_notes/entities/mastodon_user_account.dart';
import '../../dependency_injection.dart';
import '../../services/mastodon_service.dart';
import '../../utils/util.dart';

class AddMastodonUserAccount extends StatefulWidget {
final MastodonUserAccount? setting;

const AddMastodonUserAccount({super.key, this.setting});

@override
AddMastodonUserAccountState createState() => AddMastodonUserAccountState();
}

class AddMastodonUserAccountState extends State<AddMastodonUserAccount> {
final _mastodonService = locator<MastodonService>();
final TextEditingController _instanceController = TextEditingController(text: 'https://mastodon.social');

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Add Sync Setting - Mastodon'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _instanceController,
decoration: const InputDecoration(
labelText: 'Mastodon Instance URL',
hintText: 'https://mastodon.social',
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () async {
await _initializeAuthorization(context);
},
child: const Text('Authorize'),
),
],
),
),
);
}

Future<void> _initializeAuthorization(BuildContext context) async {
var scaffoldMessengerState = ScaffoldMessenger.of(context);
var navigator = Navigator.of(context);
try {
var instanceUrl = _instanceController.text.toLowerCase().trim();
if (!instanceUrl.startsWith('http://') && !instanceUrl.startsWith('https://')) {
instanceUrl = 'https://$instanceUrl';
}
if (instanceUrl.endsWith('/')) {
instanceUrl = instanceUrl.substring(0, instanceUrl.length - 1);
}
await _mastodonService.authorize(instanceUrl);
Util.showInfo(scaffoldMessengerState, 'Authorization successful');
navigator.pop();
} catch (e) {
Util.showError(scaffoldMessengerState, 'Authorization failed: $e');
}
}
}
Loading

0 comments on commit 6955d2a

Please sign in to comment.