Files
noo/lib/services/login_flow_service.dart
ayushyaandClaude Sonnet 5 d68cc43368 Add Trash/Shares/Recent tabs, file sharing, Details sheet, and rebrand to Noo
Major additions since the initial rebuild:
- New tabs: Trash, Shares, Recent, configurable tab visibility/order/default
- Nextcloud sharing: search users/teams, internal link, email shares,
  public links, "others with access" - in its own Share bottom sheet
- Details bottom sheet (Info/Versions/Activity) with a persistent peek
  panel in the media viewer, reachable from selection, swipe, and the
  media viewer
- Files/Photos: tap-and-hold multi-select with a sticky selection toolbar,
  swipe actions (favorite/delete/share), sticky sort/filter controls,
  list/grid views, breadcrumb chips, whole-account Photos view
- Media viewer: pinch/double-tap zoom on images, predictive-back support,
  fixed PDF zoom-out floor
- Settings: Material You theming incl. AMOLED black, configurable bottom
  bar tabs, swipe actions, media seek bar style, and folder-listing cache
  policy (never/periodic/manual) to cut down on refetching
- Login flow moved to an in-app Chrome Custom Tab with retry-tolerant
  polling
- Replaced ad hoc widgets with native Material 3 equivalents (toggle
  IconButtons, SliverAppBar selection toolbar, SearchBar, drag handles)
- Rebrand: app renamed to Noo throughout, package id -> dev.ayushya.noo,
  new adaptive launcher icon

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 22:44:42 -04:00

118 lines
3.5 KiB
Dart

import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
class LoginFlowInit {
final Uri loginUrl;
final Uri pollEndpoint;
final String pollToken;
const LoginFlowInit({
required this.loginUrl,
required this.pollEndpoint,
required this.pollToken,
});
}
class LoginFlowResult {
final String serverUrl;
final String loginName;
final String appPassword;
const LoginFlowResult({
required this.serverUrl,
required this.loginName,
required this.appPassword,
});
}
/// Implements Nextcloud's "Login Flow v2": the app never sees the user's
/// real password. It asks the server for a one-time login URL, the user
/// authorizes in their browser, and the app polls until the server hands
/// back a scoped app password.
/// https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
class LoginFlowService {
static const _userAgent = 'Noo/1.0';
static String normalizeServerUrl(String input) {
var url = input.trim();
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://$url';
}
if (url.endsWith('/')) {
url = url.substring(0, url.length - 1);
}
return url;
}
static Future<LoginFlowInit> initiate(String serverUrl) async {
final cleanUrl = normalizeServerUrl(serverUrl);
final endpoint = Uri.parse('$cleanUrl/index.php/login/v2');
debugPrint('[LoginFlow] Initiating login flow at $endpoint');
final response = await http.post(
endpoint,
headers: {'User-Agent': _userAgent, 'OCS-APIRequest': 'true'},
);
if (response.statusCode != 200) {
throw Exception(
'Could not start login flow (HTTP ${response.statusCode}). '
'Check the server address and that it is a reachable Nextcloud instance.',
);
}
final data = jsonDecode(response.body);
final loginUrl = data['login'] as String?;
final poll = data['poll'] as Map?;
final pollToken = poll?['token'] as String?;
final pollEndpoint = poll?['endpoint'] as String?;
if (loginUrl == null || pollToken == null || pollEndpoint == null) {
throw Exception('Server response did not include login flow details.');
}
return LoginFlowInit(
loginUrl: Uri.parse(loginUrl),
pollEndpoint: Uri.parse(pollEndpoint),
pollToken: pollToken,
);
}
/// Performs a single poll attempt. Returns null while the user has not
/// finished authorizing yet (server responds 404 during that window).
static Future<LoginFlowResult?> poll(Uri pollEndpoint, String token) async {
final response = await http.post(
pollEndpoint,
headers: {
'User-Agent': _userAgent,
'OCS-APIRequest': 'true',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: {'token': token},
);
if (response.statusCode == 404) {
return null;
}
if (response.statusCode != 200) {
throw Exception('Login poll failed (HTTP ${response.statusCode}).');
}
final data = jsonDecode(response.body);
final server = data['server'] as String?;
final loginName = data['loginName'] as String?;
final appPassword = data['appPassword'] as String?;
if (server == null || loginName == null || appPassword == null) {
throw Exception('Server response did not include app credentials.');
}
return LoginFlowResult(
serverUrl: server,
loginName: loginName,
appPassword: appPassword,
);
}
}