Skip to content

Commit 51a8c27

Browse files
authored
Add web social-post feature and Buffer settings (#102)
Introduce web social-post functionality: add SocialPostRecord model and SocialCaptions, pure decision logic (social_post_logic), and a full WebSocialPostService that submits/polls jobs, persists encrypted sidecar manifests, handles idempotency, pricing, and a Buffer proxy (save/test/delete key). UI/web changes: add Buffer settings screen, route, and Integrations section; integrate social-post actions and IPFS public disclaimer into website detail screen; add shared IPFS disclaimer widget and keep previous API via a shim. Also add secure storage key for buffer_api_key, web social widgets, and unit tests for model and logic.
1 parent 77e60bc commit 51a8c27

14 files changed

Lines changed: 2247 additions & 78 deletions
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import 'package:fula_files/core/services/ipfs_gateway_helper.dart';
2+
3+
/// Job status of a social-post generation, mirroring the backend enum.
4+
/// Unknown wire values map to [error] — safe for resume logic (no loop
5+
/// ever restarts against a status it doesn't understand).
6+
enum SocialPostStatus {
7+
pending,
8+
generating,
9+
publishing,
10+
completed,
11+
error;
12+
13+
static SocialPostStatus fromWire(String? value) => switch (value) {
14+
'pending' => SocialPostStatus.pending,
15+
'generating' => SocialPostStatus.generating,
16+
'publishing' => SocialPostStatus.publishing,
17+
'completed' => SocialPostStatus.completed,
18+
_ => SocialPostStatus.error,
19+
};
20+
21+
String get wire => name;
22+
23+
bool get isRunning =>
24+
this == SocialPostStatus.pending ||
25+
this == SocialPostStatus.generating ||
26+
this == SocialPostStatus.publishing;
27+
}
28+
29+
/// The two captions a run produces: long (Instagram/Facebook) and short
30+
/// (Twitter/X, ≤280 chars including the website URL).
31+
class SocialCaptions {
32+
final String long;
33+
final String short;
34+
const SocialCaptions({required this.long, required this.short});
35+
36+
static SocialCaptions? fromJson(Map<String, dynamic>? json) {
37+
if (json == null) return null;
38+
final long = json['long'];
39+
final short = json['short'];
40+
if (long is! String || short is! String) return null;
41+
return SocialCaptions(long: long, short: short);
42+
}
43+
44+
Map<String, dynamic> toJson() => {'long': long, 'short': short};
45+
}
46+
47+
/// One generated social post, keyed by the website generation it belongs
48+
/// to (a re-run overwrites the entry — one post per generation by design).
49+
/// Plain Dart (no Hive): persisted only in the encrypted cloud sidecar
50+
/// manifest `.fula/website_social/{userId16}.json`.
51+
class SocialPostRecord {
52+
final String generationId;
53+
final String tagId;
54+
final String? jobId;
55+
final SocialPostStatus status;
56+
final String? statusMessage;
57+
final String? errorMessage;
58+
final String? imageCid;
59+
final String? imageUrl;
60+
final SocialCaptions? captions;
61+
final String? websiteUrl;
62+
final DateTime createdAt;
63+
final DateTime updatedAt;
64+
65+
const SocialPostRecord({
66+
required this.generationId,
67+
required this.tagId,
68+
this.jobId,
69+
required this.status,
70+
this.statusMessage,
71+
this.errorMessage,
72+
this.imageCid,
73+
this.imageUrl,
74+
this.captions,
75+
this.websiteUrl,
76+
required this.createdAt,
77+
required this.updatedAt,
78+
});
79+
80+
/// Display URL for the generated image: the server-reported URL when
81+
/// present, else rebuilt from the CID via the user's gateway template.
82+
String? get resolvedImageUrl {
83+
if (imageUrl != null && imageUrl!.isNotEmpty) return imageUrl;
84+
final cid = imageCid;
85+
if (cid == null || cid.isEmpty) return null;
86+
return IpfsGatewayHelper.buildUrlForCid(cid);
87+
}
88+
89+
SocialPostRecord copyWith({
90+
String? jobId,
91+
SocialPostStatus? status,
92+
String? statusMessage,
93+
String? errorMessage,
94+
String? imageCid,
95+
String? imageUrl,
96+
SocialCaptions? captions,
97+
String? websiteUrl,
98+
DateTime? updatedAt,
99+
}) {
100+
return SocialPostRecord(
101+
generationId: generationId,
102+
tagId: tagId,
103+
jobId: jobId ?? this.jobId,
104+
status: status ?? this.status,
105+
statusMessage: statusMessage ?? this.statusMessage,
106+
errorMessage: errorMessage ?? this.errorMessage,
107+
imageCid: imageCid ?? this.imageCid,
108+
imageUrl: imageUrl ?? this.imageUrl,
109+
captions: captions ?? this.captions,
110+
websiteUrl: websiteUrl ?? this.websiteUrl,
111+
createdAt: createdAt,
112+
updatedAt: updatedAt ?? DateTime.now(),
113+
);
114+
}
115+
116+
/// Null on malformed input (missing key fields) — callers skip the entry
117+
/// rather than dropping the whole sidecar.
118+
static SocialPostRecord? fromJson(Map<String, dynamic> json) {
119+
final generationId = json['generationId'];
120+
if (generationId is! String || generationId.isEmpty) return null;
121+
DateTime parseDate(dynamic v) =>
122+
v is String ? (DateTime.tryParse(v) ?? DateTime.now()) : DateTime.now();
123+
return SocialPostRecord(
124+
generationId: generationId,
125+
tagId: json['tagId'] as String? ?? '',
126+
jobId: json['jobId'] as String?,
127+
status: SocialPostStatus.fromWire(json['status'] as String?),
128+
statusMessage: json['statusMessage'] as String?,
129+
errorMessage: json['errorMessage'] as String?,
130+
imageCid: json['imageCid'] as String?,
131+
imageUrl: json['imageUrl'] as String?,
132+
captions: SocialCaptions.fromJson(
133+
(json['captions'] as Map?)?.cast<String, dynamic>()),
134+
websiteUrl: json['websiteUrl'] as String?,
135+
createdAt: parseDate(json['createdAt']),
136+
updatedAt: parseDate(json['updatedAt']),
137+
);
138+
}
139+
140+
Map<String, dynamic> toJson() => {
141+
'generationId': generationId,
142+
'tagId': tagId,
143+
'jobId': jobId,
144+
'status': status.wire,
145+
'statusMessage': statusMessage,
146+
'errorMessage': errorMessage,
147+
'imageCid': imageCid,
148+
'imageUrl': imageUrl,
149+
'captions': captions?.toJson(),
150+
'websiteUrl': websiteUrl,
151+
'createdAt': createdAt.toIso8601String(),
152+
'updatedAt': updatedAt.toIso8601String(),
153+
};
154+
}

lib/core/services/secure_storage_service.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,11 @@ class SecureStorageKeys {
124124
static const String ipfsGatewayUrl = 'ipfs_gateway_url';
125125
static const String analyticsEndpointUrl = 'analytics_endpoint_url';
126126

127+
// Buffer (social posting) personal API key — device-local v1 (no cloud
128+
// roaming). Grants posting to the user's Buffer channels; only ever sent
129+
// to the AI-backend proxy, never to third parties from the client.
130+
static const String bufferApiKey = 'buffer_api_key';
131+
127132
// IPFS upload endpoint (ipfs-server with /upload and /gateway)
128133
static const String ipfsEndpointUrl = 'ipfs_endpoint_url';
129134

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// Pure decision logic for the social-posts feature — IO-free and
2+
// VM-testable (house rule: the service/XHR layer stays logic-free).
3+
4+
import 'package:fula_files/core/services/website_prompt_builder.dart';
5+
6+
/// Raster image extensions eligible as Gemini reference material (no SVG).
7+
const Set<String> kSocialImageExtensions = {
8+
'png', 'jpg', 'jpeg', 'webp', 'gif',
9+
};
10+
11+
/// Backend cap on reference assets per request.
12+
const int kSocialMaxAssets = 14;
13+
14+
/// Sanitized bucket prefix for a website group — byte-identical to the
15+
/// transform native + web use for `website-assets` keys
16+
/// (`replaceAll(RegExp(r'[^a-zA-Z0-9_\-]'), '_')`), clamped to the
17+
/// backend's 100-char assetPrefix bound.
18+
String socialAssetPrefix(String displayName) {
19+
final sanitized = displayName.replaceAll(RegExp(r'[^a-zA-Z0-9_\-]'), '_');
20+
final clamped =
21+
sanitized.length > 100 ? sanitized.substring(0, 100) : sanitized;
22+
return clamped.isEmpty ? '_' : clamped;
23+
}
24+
25+
bool _isImageFileName(String fileName) {
26+
final dot = fileName.lastIndexOf('.');
27+
if (dot < 0 || dot == fileName.length - 1) return false;
28+
return kSocialImageExtensions.contains(
29+
fileName.substring(dot + 1).toLowerCase());
30+
}
31+
32+
/// Request body for POST /api/v1/social/generate. Filters the generation's
33+
/// assets to URL-backed raster images and caps at [kSocialMaxAssets].
34+
Map<String, dynamic> buildSocialGeneratePayload({
35+
required String generationId,
36+
required String websiteUrl,
37+
required String userPrompt,
38+
required List<({String fileName, String type, String url})> assets,
39+
required String displayName,
40+
}) {
41+
final images = assets
42+
.where((a) => a.url.isNotEmpty && _isImageFileName(a.fileName))
43+
.take(kSocialMaxAssets)
44+
.map((a) => {'fileName': a.fileName, 'type': a.type, 'url': a.url})
45+
.toList();
46+
return {
47+
'generationId': generationId,
48+
'websiteUrl': websiteUrl,
49+
'prompt': userPrompt,
50+
'assets': images,
51+
'assetPrefix': socialAssetPrefix(displayName),
52+
};
53+
}
54+
55+
/// Website URL to embed in captions: the stable front door when the group
56+
/// has one, else the generation's own gateway URL.
57+
String? resolveSocialWebsiteUrl(
58+
String? frontDoorUrl, String? generationGatewayUrl) {
59+
if (frontDoorUrl != null && frontDoorUrl.isNotEmpty) return frontDoorUrl;
60+
if (generationGatewayUrl != null && generationGatewayUrl.isNotEmpty) {
61+
return generationGatewayUrl;
62+
}
63+
return null;
64+
}
65+
66+
/// Human prompt for the caption/image brief: the user's own words,
67+
/// stripped of the enriched Website Name/Category/Styles/… envelope.
68+
String socialUserPrompt(String storedPrompt) {
69+
final parsed = parseStoredWebsitePrompt(storedPrompt);
70+
return parsed.userBody.isNotEmpty ? parsed.userBody : storedPrompt.trim();
71+
}
72+
73+
/// Merge sidecar entries from any number of blobs (+ the local map),
74+
/// keyed by generationId, LATEST-updatedAt-wins. Differs deliberately
75+
/// from the first-wins generations/pointers merges: social entries mutate
76+
/// (pending → completed) and re-runs replace results, so the newest write
77+
/// must win. Raw maps in/out — unknown keys survive rewrites by old
78+
/// clients. Malformed entries are skipped without dropping siblings.
79+
Map<String, Map<String, dynamic>> mergeSocialPosts(
80+
Iterable<Iterable<dynamic>> entryLists) {
81+
final byId = <String, Map<String, dynamic>>{};
82+
DateTime updatedAtOf(Map<String, dynamic> m) {
83+
final v = m['updatedAt'];
84+
return v is String
85+
? (DateTime.tryParse(v) ?? DateTime.fromMillisecondsSinceEpoch(0))
86+
: DateTime.fromMillisecondsSinceEpoch(0);
87+
}
88+
89+
for (final list in entryLists) {
90+
for (final raw in list) {
91+
if (raw is! Map) continue;
92+
final m = raw.cast<String, dynamic>();
93+
final id = m['generationId'];
94+
if (id is! String || id.isEmpty) continue;
95+
final existing = byId[id];
96+
if (existing == null ||
97+
updatedAtOf(m).isAfter(updatedAtOf(existing))) {
98+
byId[id] = m;
99+
}
100+
}
101+
}
102+
return byId;
103+
}
104+
105+
/// Caption choice per Buffer channel: short for X/Twitter-like services,
106+
/// long everywhere else.
107+
String captionForBufferService(String service,
108+
{required String long, required String short}) {
109+
final s = service.toLowerCase();
110+
final isX = s.contains('twitter') || s == 'x' || s.startsWith('x_');
111+
return isX ? short : long;
112+
}
113+
114+
/// What load() should do with a sidecar record found at startup.
115+
enum SocialResumeAction { none, resumePoll, markInterrupted }
116+
117+
/// Resume decision for a record. Running + jobId → resume REGARDLESS of
118+
/// age (poll-first: the server may have finished while every tab was
119+
/// closed; a genuinely dead job answers 404/error on the first poll).
120+
/// A pending record with no jobId means the owning tab died between click
121+
/// and 202 — stale after [interruptedAfter].
122+
SocialResumeAction socialResumeAction({
123+
required String status,
124+
required String? jobId,
125+
required DateTime createdAt,
126+
required DateTime now,
127+
Duration interruptedAfter = const Duration(minutes: 5),
128+
}) {
129+
final running =
130+
status == 'pending' || status == 'generating' || status == 'publishing';
131+
if (!running) return SocialResumeAction.none;
132+
if (jobId != null && jobId.isNotEmpty) return SocialResumeAction.resumePoll;
133+
return now.difference(createdAt) > interruptedAfter
134+
? SocialResumeAction.markInterrupted
135+
: SocialResumeAction.none;
136+
}
137+
138+
/// Poll backoff: ×1.5 capped at 10s (website-gen parity).
139+
Duration nextSocialPollInterval(Duration current) {
140+
const max = Duration(seconds: 10);
141+
if (current >= max) return max;
142+
final next = Duration(milliseconds: (current.inMilliseconds * 1.5).toInt());
143+
return next > max ? max : next;
144+
}
145+
146+
/// One-line summary + all-ok flag for a Buffer post run.
147+
({String summary, bool allOk}) summarizeBufferResults(
148+
List<({String channelId, bool ok, String? error})> results) {
149+
final okCount = results.where((r) => r.ok).length;
150+
final total = results.length;
151+
if (total == 0) return (summary: 'No channels selected', allOk: false);
152+
if (okCount == total) {
153+
return (
154+
summary: total == 1
155+
? 'Posted to your Buffer queue'
156+
: 'Posted to all $total channels',
157+
allOk: true,
158+
);
159+
}
160+
return (summary: 'Posted to $okCount of $total channels', allOk: false);
161+
}

0 commit comments

Comments
 (0)