Compare commits

...

4 Commits

Author SHA1 Message Date
9bdd08d8dd New protocol to upload file 2025-09-21 18:46:48 +08:00
d737232dcf 💄 Optimize auth devices 2025-09-21 15:33:09 +08:00
c9d751479e 🐛 Fix iOS build 2025-09-21 15:32:52 +08:00
a2c2bfe585 ♻️ Replace the pattle_generator 2025-09-21 14:50:30 +08:00
14 changed files with 495 additions and 152 deletions

View File

@@ -447,6 +447,8 @@
"lastActiveAt": "Last active at {}", "lastActiveAt": "Last active at {}",
"authDeviceLogout": "Logout", "authDeviceLogout": "Logout",
"authDeviceLogoutHint": "Are you sure you want to logout this device? This will also disable the push notification to this device.", "authDeviceLogoutHint": "Are you sure you want to logout this device? This will also disable the push notification to this device.",
"authDeviceChallenges": "Device Usage",
"authDeviceHint": "Swipe left to edit label, swipe right to logout device.",
"typingHint": { "typingHint": {
"one": "{} is typing...", "one": "{} is typing...",
"other": "{} are typing..." "other": "{} are typing..."

View File

@@ -149,9 +149,9 @@ PODS:
- flutter_udid (0.0.1): - flutter_udid (0.0.1):
- Flutter - Flutter
- SAMKeychain - SAMKeychain
- flutter_webrtc (1.1.0): - flutter_webrtc (1.2.0):
- Flutter - Flutter
- WebRTC-SDK (= 137.7151.03) - WebRTC-SDK (= 137.7151.04)
- gal (1.0.0): - gal (1.0.0):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
@@ -219,7 +219,7 @@ PODS:
- livekit_client (2.5.0): - livekit_client (2.5.0):
- Flutter - Flutter
- flutter_webrtc - flutter_webrtc
- WebRTC-SDK (= 137.7151.03) - WebRTC-SDK (= 137.7151.04)
- local_auth_darwin (0.0.1): - local_auth_darwin (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
@@ -299,7 +299,7 @@ PODS:
- Flutter - Flutter
- wakelock_plus (0.0.1): - wakelock_plus (0.0.1):
- Flutter - Flutter
- WebRTC-SDK (137.7151.03) - WebRTC-SDK (137.7151.04)
DEPENDENCIES: DEPENDENCIES:
- Alamofire - Alamofire
@@ -499,7 +499,7 @@ SPEC CHECKSUMS:
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544 flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544
flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9 flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9
flutter_webrtc: b0b2e04411747142962164a1cfa43a1af9a0afac flutter_webrtc: c3e21fc0dcd9d8eb246ae4d5256fcbeb2f5ecd22
gal: baecd024ebfd13c441269ca7404792a7152fde89 gal: baecd024ebfd13c441269ca7404792a7152fde89
GoogleAdsOnDeviceConversion: 9090c435cde08903e8dd1ba2c77fbec9e46d9afe GoogleAdsOnDeviceConversion: 9090c435cde08903e8dd1ba2c77fbec9e46d9afe
GoogleAppMeasurement: 09f341dfa8527d1612a09cbfe809a242c0b737af GoogleAppMeasurement: 09f341dfa8527d1612a09cbfe809a242c0b737af
@@ -508,8 +508,8 @@ SPEC CHECKSUMS:
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486 irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486
Kingfisher: ff0d31a1f07bdff6a1ebb3ba08b8e6e567b6500c Kingfisher: ff0d31a1f07bdff6a1ebb3ba08b8e6e567b6500c
livekit_client: f810c81bbbc229a84f60b09e66603ac4e93f7599 livekit_client: a6f5fa86ac28ccd7ded53626a5379961db311ab4
local_auth_darwin: d2e8c53ef0c4f43c646462e3415432c4dab3ae19 local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb
media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854
media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474 media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
@@ -536,7 +536,7 @@ SPEC CHECKSUMS:
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12 volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
WebRTC-SDK: 69d4e56b0b4b27d788e87bab9b9a1326ed05b1e3 WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e
PODFILE CHECKSUM: c818292390b02fa379036ea099713a332bd7193f PODFILE CHECKSUM: c818292390b02fa379036ea099713a332bd7193f

View File

@@ -566,7 +566,7 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin\n";
}; };
4815E0A19398E51078F4160D /* [CP] Check Pods Manifest.lock */ = { 4815E0A19398E51078F4160D /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
@@ -883,6 +883,7 @@
); );
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian; PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ENABLE_EXPLICIT_MODULES = "$(SWIFT_USE_INTEGRATED_DRIVER)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic"; VERSIONING_SYSTEM = "apple-generic";
@@ -1096,6 +1097,7 @@
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
@@ -1137,6 +1139,7 @@
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
}; };
@@ -1177,6 +1180,7 @@
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
}; };
@@ -1434,6 +1438,7 @@
); );
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian; PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ENABLE_EXPLICIT_MODULES = "$(SWIFT_USE_INTEGRATED_DRIVER)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -1462,6 +1467,7 @@
); );
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian; PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ENABLE_EXPLICIT_MODULES = "$(SWIFT_USE_INTEGRATED_DRIVER)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic"; VERSIONING_SYSTEM = "apple-generic";

View File

@@ -44,9 +44,12 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> {
: 'failedToLoadUserInfoNetwork') : 'failedToLoadUserInfoNetwork')
.tr() .tr()
.trim(), .trim(),
'${error.response?.statusCode ?? 'Network Error'}\n${error.response?.headers}', '',
jsonEncode(error.response?.data), '${error.response?.statusCode ?? 'Network Error'}',
].join('\n\n'), if (error.response?.headers != null) error.response?.headers,
if (error.response?.data != null)
jsonEncode(error.response?.data),
].join('\n'),
iconStyle: IconStyle.error, iconStyle: IconStyle.error,
neutralButtonTitle: 'retry'.tr(), neutralButtonTitle: 'retry'.tr(),
negativeButtonTitle: 'okay'.tr(), negativeButtonTitle: 'okay'.tr(),

View File

@@ -32,7 +32,7 @@ import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/markdown.dart'; import 'package:island/widgets/content/markdown.dart';
import 'package:island/widgets/safety/abuse_report_helper.dart'; import 'package:island/widgets/safety/abuse_report_helper.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:palette_generator/palette_generator.dart'; import 'package:island/services/color_extraction.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
@@ -581,14 +581,14 @@ Future<Color?> accountAppbarForcegroundColor(Ref ref, String uname) async {
try { try {
final account = await ref.watch(accountProvider(uname).future); final account = await ref.watch(accountProvider(uname).future);
if (account.profile.background == null) return null; if (account.profile.background == null) return null;
final palette = await PaletteGenerator.fromImageProvider( final colors = await ColorExtractionService.getColorsFromImage(
CloudImageWidget.provider( CloudImageWidget.provider(
fileId: account.profile.background!.id, fileId: account.profile.background!.id,
serverUrl: ref.watch(serverUrlProvider), serverUrl: ref.watch(serverUrlProvider),
), ),
); );
final dominantColor = palette.dominantColor?.color; if (colors.isEmpty) return null;
if (dominantColor == null) return null; final dominantColor = colors.first;
return dominantColor.computeLuminance() > 0.5 ? Colors.black : Colors.white; return dominantColor.computeLuminance() > 0.5 ? Colors.black : Colors.white;
} catch (_) { } catch (_) {
return null; return null;

View File

@@ -21,7 +21,7 @@ import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/markdown.dart'; import 'package:island/widgets/content/markdown.dart';
import 'package:island/widgets/post/post_list.dart'; import 'package:island/widgets/post/post_list.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:palette_generator/palette_generator.dart'; import 'package:island/services/color_extraction.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
@@ -278,14 +278,14 @@ Future<Color?> publisherAppbarForcegroundColor(Ref ref, String pubName) async {
try { try {
final publisher = await ref.watch(publisherProvider(pubName).future); final publisher = await ref.watch(publisherProvider(pubName).future);
if (publisher.background == null) return null; if (publisher.background == null) return null;
final palette = await PaletteGenerator.fromImageProvider( final colors = await ColorExtractionService.getColorsFromImage(
CloudImageWidget.provider( CloudImageWidget.provider(
fileId: publisher.background!.id, fileId: publisher.background!.id,
serverUrl: ref.watch(serverUrlProvider), serverUrl: ref.watch(serverUrlProvider),
), ),
); );
final dominantColor = palette.dominantColor?.color; if (colors.isEmpty) return null;
if (dominantColor == null) return null; final dominantColor = colors.first;
return dominantColor.computeLuminance() > 0.5 ? Colors.black : Colors.white; return dominantColor.computeLuminance() > 0.5 ? Colors.black : Colors.white;
} catch (_) { } catch (_) {
return null; return null;

View File

@@ -8,7 +8,7 @@ import 'package:island/services/responsive.dart';
import 'package:island/widgets/account/account_pfc.dart'; import 'package:island/widgets/account/account_pfc.dart';
import 'package:island/widgets/account/status.dart'; import 'package:island/widgets/account/status.dart';
import 'package:island/widgets/post/post_list.dart'; import 'package:island/widgets/post/post_list.dart';
import 'package:palette_generator/palette_generator.dart'; import 'package:island/services/color_extraction.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -32,14 +32,14 @@ part 'realm_detail.g.dart';
Future<Color?> realmAppbarForegroundColor(Ref ref, String realmSlug) async { Future<Color?> realmAppbarForegroundColor(Ref ref, String realmSlug) async {
final realm = await ref.watch(realmProvider(realmSlug).future); final realm = await ref.watch(realmProvider(realmSlug).future);
if (realm?.background == null) return null; if (realm?.background == null) return null;
final palette = await PaletteGenerator.fromImageProvider( final colors = await ColorExtractionService.getColorsFromImage(
CloudImageWidget.provider( CloudImageWidget.provider(
fileId: realm!.background!.id, fileId: realm!.background!.id,
serverUrl: ref.watch(serverUrlProvider), serverUrl: ref.watch(serverUrlProvider),
), ),
); );
final dominantColor = palette.dominantColor?.color; if (colors.isEmpty) return null;
if (dominantColor == null) return null; final dominantColor = colors.first;
return dominantColor.computeLuminance() > 0.5 ? Colors.black : Colors.white; return dominantColor.computeLuminance() > 0.5 ? Colors.black : Colors.white;
} }

View File

@@ -12,11 +12,11 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/services/color_extraction.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
@@ -293,24 +293,26 @@ class SettingsScreen extends HookConsumerWidget {
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),
onTap: () async { onTap: () async {
showLoadingModal(context); showLoadingModal(context);
final palette = await PaletteGenerator.fromImageProvider( final colors = await ColorExtractionService.getColorsFromImage(
FileImage( FileImage(
File('${docBasepath.value}/$kAppBackgroundImagePath'), File('${docBasepath.value}/$kAppBackgroundImagePath'),
), ),
); );
if (palette.darkVibrantColor == null || if (colors.isEmpty) {
palette.lightVibrantColor == null) {
if (context.mounted) hideLoadingModal(context); if (context.mounted) hideLoadingModal(context);
showErrorAlert( showErrorAlert(
'Unable to calculate the domiant color of the background image.', 'Unable to calculate the dominant color of the background image.',
); );
return; return;
} }
if (!context.mounted) return; if (!context.mounted) return;
final colorScheme = ColorScheme.fromSeed(
seedColor: colors.first,
);
final color = final color =
MediaQuery.of(context).platformBrightness == Brightness.dark MediaQuery.of(context).platformBrightness == Brightness.dark
? palette.darkVibrantColor!.color ? colorScheme.primary
: palette.lightVibrantColor!.color; : colorScheme.primary;
ref ref
.read(appSettingsNotifierProvider.notifier) .read(appSettingsNotifierProvider.notifier)
.setAppColorScheme(color.value); .setAppColorScheme(color.value);

View File

@@ -0,0 +1,49 @@
import 'package:flutter/widgets.dart';
import 'package:image/image.dart' as img;
import 'package:material_color_utilities/material_color_utilities.dart' as mcu;
class ColorExtractionService {
/// Extracts dominant colors from an image provider.
/// Returns a list of colors suitable for UI theming.
static Future<List<Color>> getColorsFromImage(ImageProvider provider) async {
try {
if (provider is FileImage) {
final bytes = await provider.file.readAsBytes();
final image = img.decodeImage(bytes);
if (image == null) return [];
final Map<int, int> colorToCount = {};
for (int y = 0; y < image.height; y++) {
for (int x = 0; x < image.width; x++) {
final pixel = image.getPixel(x, y) as int;
final r = (pixel >> 24) & 0xff;
final g = (pixel >> 16) & 0xff;
final b = (pixel >> 8) & 0xff;
final a = pixel & 0xff;
if (a == 0) continue;
final argb = (a << 24) | (r << 16) | (g << 8) | b;
colorToCount[argb] = (colorToCount[argb] ?? 0) + 1;
}
}
final List<int> filteredResults = mcu.Score.score(
colorToCount,
desired: 1,
filter: true,
);
final List<int> scoredResults = mcu.Score.score(
colorToCount,
desired: 4,
filter: false,
);
return <dynamic>{
...filteredResults,
...scoredResults,
}.toList().map((argb) => Color(argb)).toList();
} else {
return [];
}
} catch (e) {
debugPrint('Error getting colors from image: $e');
return [];
}
}
}

View File

@@ -1,15 +1,15 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:ui'; import 'dart:ui';
import 'package:croppy/croppy.dart'; import 'package:croppy/croppy.dart';
import 'package:cross_file/cross_file.dart'; import 'package:cross_file/cross_file.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/services/file_uploader.dart';
import 'package:native_exif/native_exif.dart'; import 'package:native_exif/native_exif.dart';
import 'package:tus_client_dart/tus_client_dart.dart'; import 'package:path_provider/path_provider.dart';
Future<XFile?> cropImage( Future<XFile?> cropImage(
BuildContext context, { BuildContext context, {
@@ -44,6 +44,7 @@ Completer<SnCloudFile?> putMediaToCloud({
required UniversalFile fileData, required UniversalFile fileData,
required String atk, required String atk,
required String baseUrl, required String baseUrl,
String? poolId,
String? filename, String? filename,
String? mimetype, String? mimetype,
Function(double progress, Duration estimate)? onProgress, Function(double progress, Duration estimate)? onProgress,
@@ -85,6 +86,7 @@ Completer<SnCloudFile?> putMediaToCloud({
fileData, fileData,
atk, atk,
baseUrl, baseUrl,
poolId,
filename, filename,
mimetype, mimetype,
onProgress, onProgress,
@@ -98,6 +100,7 @@ Completer<SnCloudFile?> putMediaToCloud({
fileData, fileData,
atk, atk,
baseUrl, baseUrl,
poolId,
filename, filename,
mimetype, mimetype,
onProgress, onProgress,
@@ -114,6 +117,7 @@ Completer<SnCloudFile?> putMediaToCloud({
fileData, fileData,
atk, atk,
baseUrl, baseUrl,
poolId,
filename, filename,
mimetype, mimetype,
onProgress, onProgress,
@@ -127,6 +131,7 @@ Completer<SnCloudFile?> _processUpload(
UniversalFile fileData, UniversalFile fileData,
String atk, String atk,
String baseUrl, String baseUrl,
String? poolId,
String? filename, String? filename,
String? mimetype, String? mimetype,
Function(double progress, Duration estimate)? onProgress, Function(double progress, Duration estimate)? onProgress,
@@ -168,26 +173,80 @@ Completer<SnCloudFile?> _processUpload(
return completer; return completer;
} }
final Map<String, String> metadata = { // Create Dio instance
'filename': actualFilename, final dio = Dio(
'content-type': actualMimetype, BaseOptions(
}; baseUrl: baseUrl,
headers: {
'Authorization': 'AtField $atk',
'Accept': 'application/json',
'Content-Type': 'application/json',
},
),
);
final client = TusClient(file); final uploader = FileUploader(dio);
client
.upload( // Get File object
uri: Uri.parse('$baseUrl/drive/tus'), File fileObj;
headers: {'Authorization': 'AtField $atk'}, if (file.path.isNotEmpty) {
metadata: metadata, fileObj = File(file.path);
onComplete: (lastResponse) { // Call progress start
final resp = jsonDecode(lastResponse!.headers['x-fileinfo']!); onProgress?.call(0.0, Duration.zero);
completer.complete(SnCloudFile.fromJson(resp)); uploader
}, .uploadFile(
onProgress: (double progress, Duration estimate) { file: fileObj,
onProgress?.call(progress, estimate); fileName: actualFilename,
}, contentType: actualMimetype,
) poolId: poolId,
.catchError(completer.completeError); )
.then((result) {
// Call progress end
onProgress?.call(1.0, Duration.zero);
completer.complete(result);
})
.catchError((e) {
completer.completeError(e);
throw e;
});
} else {
// Write to temp file
getTemporaryDirectory()
.then((tempDir) {
final tempFile = File('${tempDir.path}/temp_upload_$actualFilename');
tempFile
.writeAsBytes(byteData!)
.then((_) {
fileObj = tempFile;
// Call progress start
onProgress?.call(0.0, Duration.zero);
uploader
.uploadFile(
file: fileObj,
fileName: actualFilename,
contentType: actualMimetype,
poolId: poolId,
)
.then((result) {
// Call progress end
onProgress?.call(1.0, Duration.zero);
completer.complete(result);
})
.catchError((e) {
completer.completeError(e);
throw e;
});
})
.catchError((e) {
completer.completeError(e);
throw e;
});
})
.catchError((e) {
completer.completeError(e);
throw e;
});
}
return completer; return completer;
} }

View File

@@ -0,0 +1,155 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/pods/network.dart';
class FileUploader {
final Dio _dio;
FileUploader(this._dio);
/// Calculates the MD5 hash of a file.
Future<String> _calculateFileHash(File file) async {
final bytes = await file.readAsBytes();
final digest = md5.convert(bytes);
return digest.toString();
}
/// Creates an upload task for the given file.
Future<Map<String, dynamic>> createUploadTask({
required File file,
required String fileName,
required String contentType,
String? poolId,
String? bundleId,
String? encryptPassword,
String? expiredAt,
int? chunkSize,
}) async {
final hash = await _calculateFileHash(file);
final fileSize = await file.length();
final response = await _dio.post(
'/drive/files/upload/create',
data: {
'hash': hash,
'file_name': fileName,
'file_size': fileSize,
'content_type': contentType,
'pool_id': poolId,
'bundle_id': bundleId,
'encrypt_password': encryptPassword,
'expired_at': expiredAt,
'chunk_size': chunkSize,
},
);
return response.data;
}
/// Uploads a single chunk of the file.
Future<void> uploadChunk({
required String taskId,
required int chunkIndex,
required Uint8List chunkData,
}) async {
final formData = FormData.fromMap({
'chunk': MultipartFile.fromBytes(
chunkData,
filename: 'chunk_$chunkIndex',
),
});
await _dio.post(
'/drive/files/upload/chunk/$taskId/$chunkIndex',
data: formData,
);
}
/// Completes the upload and returns the CloudFile object.
Future<SnCloudFile> completeUpload(String taskId) async {
final response = await _dio.post('/drive/files/upload/complete/$taskId');
return SnCloudFile.fromJson(response.data);
}
/// Uploads a file in chunks using the multi-part API.
Future<SnCloudFile> uploadFile({
required File file,
required String fileName,
required String contentType,
String? poolId,
String? bundleId,
String? encryptPassword,
String? expiredAt,
int? customChunkSize,
}) async {
// Step 1: Create upload task
final createResponse = await createUploadTask(
file: file,
fileName: fileName,
contentType: contentType,
poolId: poolId,
bundleId: bundleId,
encryptPassword: encryptPassword,
expiredAt: expiredAt,
chunkSize: customChunkSize,
);
if (createResponse['file_exists'] == true) {
// File already exists, return the existing file
return SnCloudFile.fromJson(createResponse['file']);
}
final taskId = createResponse['task_id'] as String;
final chunkSize = createResponse['chunk_size'] as int;
final chunksCount = createResponse['chunks_count'] as int;
// Step 2: Upload chunks
final stream = file.openRead();
final chunks = <Uint8List>[];
int bytesRead = 0;
final buffer = BytesBuilder();
await for (final chunk in stream) {
buffer.add(chunk);
bytesRead += chunk.length;
if (bytesRead >= chunkSize) {
chunks.add(buffer.takeBytes());
bytesRead = 0;
}
}
// Add remaining bytes as last chunk
if (buffer.length > 0) {
chunks.add(buffer.takeBytes());
}
// Ensure we have the correct number of chunks
if (chunks.length != chunksCount) {
throw Exception(
'Chunk count mismatch: expected $chunksCount, got ${chunks.length}',
);
}
// Upload each chunk
for (int i = 0; i < chunks.length; i++) {
await uploadChunk(taskId: taskId, chunkIndex: i, chunkData: chunks[i]);
}
// Step 3: Complete upload
return await completeUpload(taskId);
}
}
// Riverpod provider for the FileUploader service
final fileUploaderProvider = Provider<FileUploader>((ref) {
final dio = ref.watch(apiClientProvider);
return FileUploader(dio);
});

View File

@@ -6,10 +6,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/account.dart'; import 'package:island/models/account.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/services/time.dart';
import 'package:island/services/udid.dart'; import 'package:island/services/udid.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/response.dart'; import 'package:island/widgets/response.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:island/widgets/extended_refresh_indicator.dart'; import 'package:island/widgets/extended_refresh_indicator.dart';
@@ -43,32 +45,11 @@ class _DeviceListTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListTile( return ExpansionTile(
isThreeLine: true, title: Row(
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), spacing: 8,
leading: Icon(switch (device.platform) {
0 => Icons.device_unknown, // Unidentified
1 => Icons.web, // Web
2 => Icons.phone_iphone, // iOS
3 => Icons.phone_android, // Android
4 => Icons.laptop_mac, // macOS
5 => Icons.window, // Windows
6 => Icons.computer, // Linux
_ => Icons.device_unknown, // fallback
}).padding(top: 4),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Flexible(child: Text(device.deviceLabel ?? device.deviceName)),
'lastActiveAt'.tr(
args: [
DateFormat().format(
device.challenges.first.createdAt.toLocal(),
),
],
),
),
Text(device.challenges.first.ipAddress),
if (device.isCurrent) if (device.isCurrent)
Row( Row(
children: [ children: [
@@ -82,10 +63,29 @@ class _DeviceListTile extends StatelessWidget {
), ),
), ),
], ],
).padding(top: 4), ),
], ],
), ),
title: Text(device.deviceLabel ?? device.deviceName), subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'lastActiveAt'.tr(
args: [device.challenges.first.createdAt.formatSystem()],
),
),
],
),
leading: Icon(switch (device.platform) {
0 => Icons.device_unknown, // Unidentified
1 => Icons.web, // Web
2 => Icons.phone_iphone, // iOS
3 => Icons.phone_android, // Android
4 => Icons.laptop_mac, // macOS
5 => Icons.window, // Windows
6 => Icons.computer, // Linux
_ => Icons.device_unknown, // fallback
}).padding(top: 4),
trailing: trailing:
isWideScreen(context) isWideScreen(context)
? Row( ? Row(
@@ -105,6 +105,36 @@ class _DeviceListTile extends StatelessWidget {
], ],
) )
: null, : null,
expandedCrossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant,
),
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text('authDeviceChallenges'.tr()),
),
for (final challenge in device.challenges)
ListTile(
minTileHeight: 48,
title: Text(DateFormat().format(challenge.createdAt.toLocal())),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(challenge.ipAddress),
if (challenge.location != null)
Row(
spacing: 4,
children:
[challenge.location?.city, challenge.location?.country]
.where((e) => e?.isNotEmpty ?? false)
.map((e) => Text(e!))
.toList(),
),
],
),
),
],
); );
} }
} }
@@ -176,72 +206,116 @@ class AccountSessionSheet extends HookConsumerWidget {
return SheetScaffold( return SheetScaffold(
titleText: 'authSessions'.tr(), titleText: 'authSessions'.tr(),
child: authDevices.when( child: Column(
data: children: [
(data) => ExtendedRefreshIndicator( if (!wideScreen)
onRefresh: Container(
() => Future.sync(() => ref.invalidate(authDevicesProvider)), width: double.infinity,
child: ListView.builder( padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: EdgeInsets.zero, color: Theme.of(context).colorScheme.surfaceContainerHigh,
itemCount: data.length, child: Row(
itemBuilder: (context, index) { mainAxisAlignment: MainAxisAlignment.center,
final device = data[index]; crossAxisAlignment: CrossAxisAlignment.start,
if (wideScreen) { spacing: 8,
return _DeviceListTile( children: [
device: device, const Icon(Symbols.info, size: 16).padding(top: 2),
updateDeviceLabel: updateDeviceLabel, Flexible(
logoutDevice: logoutDevice, child: Text(
); 'authDeviceHint'.tr(),
} else { style: TextStyle(
return Dismissible( color: Theme.of(context).colorScheme.onSurfaceVariant,
key: Key('device-${device.id}'),
direction:
device.isCurrent
? DismissDirection.startToEnd
: DismissDirection.horizontal,
background: Container(
color: Colors.blue,
alignment: Alignment.centerLeft,
padding: EdgeInsets.symmetric(horizontal: 20),
child: Icon(Icons.edit, color: Colors.white),
), ),
secondaryBackground: Container( ),
color: Colors.red, ),
alignment: Alignment.centerRight, ],
padding: EdgeInsets.symmetric(horizontal: 20),
child: Icon(Icons.logout, color: Colors.white),
),
confirmDismiss: (direction) async {
if (direction == DismissDirection.startToEnd) {
updateDeviceLabel(device.deviceId);
return false;
} else {
final confirm = await showConfirmAlert(
'authDeviceLogoutHint'.tr(),
'authDeviceLogout'.tr(),
);
if (confirm && context.mounted) {
logoutDevice(device.deviceId);
}
return false; // Don't dismiss
}
},
child: _DeviceListTile(
device: device,
updateDeviceLabel: updateDeviceLabel,
logoutDevice: logoutDevice,
),
);
}
},
), ),
), ),
error: Expanded(
(err, _) => ResponseErrorWidget( child: authDevices.when(
error: err, data:
onRetry: () => ref.invalidate(authDevicesProvider), (data) => ExtendedRefreshIndicator(
onRefresh:
() => Future.sync(
() => ref.invalidate(authDevicesProvider),
),
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: data.length,
itemBuilder: (context, index) {
final device = data[index];
if (wideScreen) {
return _DeviceListTile(
device: device,
updateDeviceLabel: updateDeviceLabel,
logoutDevice: logoutDevice,
);
} else {
return Dismissible(
key: Key('device-${device.id}'),
direction:
device.isCurrent
? DismissDirection.startToEnd
: DismissDirection.horizontal,
background: Container(
color: Colors.blue,
alignment: Alignment.centerLeft,
padding: EdgeInsets.symmetric(horizontal: 20),
child: Icon(Icons.edit, color: Colors.white),
),
secondaryBackground: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: EdgeInsets.symmetric(horizontal: 20),
child: Icon(Icons.logout, color: Colors.white),
),
confirmDismiss: (direction) async {
if (direction == DismissDirection.startToEnd) {
updateDeviceLabel(device.deviceId);
return false;
} else {
final confirm = await showConfirmAlert(
'authDeviceLogoutHint'.tr(),
'authDeviceLogout'.tr(),
);
if (confirm && context.mounted) {
try {
showLoadingModal(context);
final apiClient = ref.watch(
apiClientProvider,
);
await apiClient.delete(
'/id/accounts/me/devices/${device.deviceId}',
);
ref.invalidate(authDevicesProvider);
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted)
hideLoadingModal(context);
}
}
return confirm;
}
},
child: _DeviceListTile(
device: device,
updateDeviceLabel: updateDeviceLabel,
logoutDevice: logoutDevice,
),
);
}
},
),
),
error:
(err, _) => ResponseErrorWidget(
error: err,
onRetry: () => ref.invalidate(authDevicesProvider),
),
loading: () => ResponseLoadingWidget(),
), ),
loading: () => ResponseLoadingWidget(), ),
],
), ),
); );
} }

View File

@@ -1270,7 +1270,7 @@ packages:
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image: image:
dependency: transitive dependency: "direct main"
description: description:
name: image name: image
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
@@ -1717,14 +1717,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.2.1" version: "3.2.1"
palette_generator:
dependency: "direct main"
description:
name: palette_generator
sha256: "4420f7ccc3f0a4a906144e73f8b6267cd940b64f57a7262e95cb8cec3a8ae0ed"
url: "https://pub.dev"
source: hosted
version: "0.3.3+7"
pasteboard: pasteboard:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@@ -106,10 +106,11 @@ dependencies:
livekit_client: ^2.5.1 livekit_client: ^2.5.1
pasteboard: ^0.4.0 pasteboard: ^0.4.0
flutter_colorpicker: ^1.1.0 flutter_colorpicker: ^1.1.0
image: ^4.5.4
record: ^6.1.1 record: ^6.1.1
qr_flutter: ^4.1.0 qr_flutter: ^4.1.0
flutter_otp_text_field: ^1.5.1+1 flutter_otp_text_field: ^1.5.1+1
palette_generator: ^0.3.3+7
flutter_popup_card: ^0.0.6 flutter_popup_card: ^0.0.6
timezone: ^0.10.1 timezone: ^0.10.1
flutter_timezone: ^5.0.0 flutter_timezone: ^5.0.0